Key takeaways
- Keep points, energy, upgrades and referrals on your server: the client sends batches of taps, and the server accepts the smallest of the claim, a per-second cap and server-computed energy.
- Validate Telegram's initData with your bot token on every request, and pay referral bonuses only after the invitee has really played.
- Use virtual points with clear terms instead of promising a token airdrop, and earn from rewarded ads, Stars purchases and an offerwall that pays in points.
How to make a tap-to-earn game on Telegram
To make a tap-to-earn game on Telegram, build a web app that opens as a Telegram Mini App and keep every number that matters on your server. The client shows a coin and sends batches of taps; your server validates Telegram’s initData, computes energy from timestamps, caps taps per second, and decides how many taps count. Reward players with virtual points under clear terms, not promised tokens, and earn from rewarded ads, Stars purchases and an offerwall that pays in points.
Below is the core server logic in Node.js, tested locally on Node 22. New to Mini Apps? Start with how to make a Telegram Mini App and come back for the game.
What a tap-to-earn game is, and why the 2024 wave matters
A tap-to-earn game, or Telegram clicker game, is a simple loop: tap a coin for points, spend energy on each tap, wait for it to refill, and buy upgrades that make taps or hours worth more. Most add daily tasks, referrals and a leaderboard.
The genre grew fast on Telegram in 2024. Decrypt reported on July 30, 2024 that the team behind Hamster Kombat said in its whitepaper that the game had reached 300 million players, and described Notcoin as the original Telegram tap-to-earn game (Decrypt). Both games tied play to a crypto token for players, and that is the part you should not copy, as the rules section below explains.
Who does what in a tap game
| Part | Does | Never does |
|---|---|---|
| Mini App client | Draws the coin, counts taps locally, sends batches, shows the server’s numbers | Decides points, energy or upgrades |
| Your server | Validates initData, settles energy, accepts or caps taps, sells upgrades, pays referrals, credits offerwall postbacks | Trusts a number the client sent without checking it |
| Telegram | Opens the Mini App, signs the launch data, handles Stars payments | Stores your game state |
| Offerwall | Shows offers, tracks completions, sends signed postbacks | Pays your players directly |
Anything in the client can be changed. Tap bots, edited requests and replayed batches are normal in this genre, so a tap game is really a small anti-cheat server with a coin on top.
Step 1: validate initData on every request
Telegram’s Mini Apps documentation says initDataUnsafe should not be trusted and initData should be used only on your server after validation: an HMAC-SHA256 of the sorted fields, keyed with a secret derived from your bot token and the string WebAppData, must equal hash. The docs also suggest checking auth_date.
const crypto = require('crypto');
function validateInitData(initData, botToken, maxAgeSec = 3600) {
const params = new URLSearchParams(initData);
const hash = params.get('hash') || '';
params.delete('hash');
const dataCheckString = [...params.keys()]
.sort()
.map((k) => `${k}=${params.get(k)}`)
.join('\n');
const secret = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest();
const expected = crypto.createHmac('sha256', secret).update(dataCheckString).digest('hex');
const given = Buffer.from(hash);
const wanted = Buffer.from(expected);
if (given.length !== wanted.length || !crypto.timingSafeEqual(given, wanted)) return null;
const authDate = Number(params.get('auth_date') || 0);
if (Date.now() / 1000 - authDate > maxAgeSec) return null;
const user = JSON.parse(params.get('user') || 'null');
if (!user || user.id === undefined) return null;
return { telegramId: String(user.id), startParam: params.get('start_param') || '' };
}
module.exports = { validateInitData };
The client sends the raw Telegram.WebApp.initData in an Authorization: tma ... header on every call, so there is no session to steal. The bot token stays on the server. Telegram says a user ID has at most 52 significant bits, so a JavaScript number holds it safely. The comparison uses crypto.timingSafeEqual from the Node.js crypto module.
Step 2: count taps on the server
One request per tap floods your server, and trusting a client total lets anyone send a billion. So the client batches taps for about 2 seconds and sends { seq, taps, intervals }, and the server accepts the smallest of three numbers:
- What the client claimed.
- What a human could tap since the last accepted batch, measured with the server’s clock, at most 12 taps per second over at most 10 seconds.
- The energy the player has, computed from the stored value and the time since the last sync. No timer ticks energy; it is a number plus a timestamp, settled on every request.
The sequence number makes retries safe: a batch with a seq already processed changes nothing, so a client on a bad connection can resend it.
const RULES = {
baseMaxEnergy: 1000,
regenPerSec: 1,
maxTapsPerSec: 12,
maxWindowSec: 10,
offlineCapSec: 3 * 3600,
};
function newPlayer(nowMs) {
return {
points: 0, energy: RULES.baseMaxEnergy, syncedAt: nowMs, tapAt: nowMs,
lastSeq: 0, taps: 0, tapLevel: 1, energyLevel: 1, passivePerHour: 0, carry: 0, flags: 0,
};
}
function maxEnergy(p) {
return RULES.baseMaxEnergy + (p.energyLevel - 1) * 500;
}
function settle(p, nowMs) {
const secs = Math.max(0, (nowMs - p.syncedAt) / 1000);
p.energy = Math.min(maxEnergy(p), p.energy + secs * RULES.regenPerSec);
p.carry += (Math.min(secs, RULES.offlineCapSec) * p.passivePerHour) / 3600;
const whole = Math.floor(p.carry);
p.points += whole;
p.carry -= whole;
p.syncedAt = nowMs;
}
function looksScripted(intervals) {
if (!Array.isArray(intervals) || intervals.length < 30) return false;
if (!intervals.every((x) => Number.isFinite(x) && x >= 0)) return true;
const mean = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const sd = Math.sqrt(intervals.reduce((a, b) => a + (b - mean) ** 2, 0) / intervals.length);
return mean < 40 || sd / mean < 0.05;
}
function applyBatch(p, batch, nowMs) {
if (!Number.isInteger(batch.seq) || batch.seq <= p.lastSeq) {
return { accepted: 0, duplicate: true, points: p.points, energy: Math.floor(p.energy) };
}
settle(p, nowMs);
const secs = (nowMs - p.tapAt) / 1000;
const window = Math.min(secs, RULES.maxWindowSec);
const byRate = Math.floor(window * RULES.maxTapsPerSec);
const claimed = Math.max(0, Math.floor(Number(batch.taps) || 0));
const accepted = Math.min(claimed, byRate, Math.floor(p.energy));
p.energy -= accepted;
p.points += accepted * p.tapLevel;
p.taps += accepted;
p.lastSeq = batch.seq;
p.tapAt = nowMs;
const slack = RULES.maxTapsPerSec * 2;
if (claimed > byRate + slack || looksScripted(batch.intervals)) p.flags += 1;
return { accepted, points: p.points, energy: Math.floor(p.energy) };
}
These limits are illustrative starting points, not benchmarks: measure real players in a soft launch and set maxTapsPerSec above fast humans and below scripts. The routes are small. This sketch keeps players in memory; in production, load and save each player in one database transaction with a row lock, so two devices cannot spend the same energy twice.
function auth(req, res, next) {
const raw = (req.get('Authorization') || '').replace(/^tma /, '');
const tg = validateInitData(raw, process.env.BOT_TOKEN);
if (!tg) return res.sendStatus(401);
if (!players.has(tg.telegramId)) {
const p = E.newPlayer(Date.now());
p.id = String(byAccountId.size + 1);
const ref = /^ref_(\d{1,12})$/.exec(tg.startParam);
if (ref && byAccountId.has(ref[1])) p.referrer = ref[1];
players.set(tg.telegramId, p);
byAccountId.set(p.id, p);
}
req.player = players.get(tg.telegramId);
next();
}
app.get('/api/me', auth, (req, res) => {
const p = req.player;
E.settle(p, Date.now());
res.json({ id: p.id, points: p.points, energy: Math.floor(p.energy), lastSeq: p.lastSeq, tapLevel: p.tapLevel });
});
app.post('/api/taps', auth, (req, res) => {
const p = req.player;
const result = E.applyBatch(p, req.body || {}, Date.now());
if (p.referrer) E.payReferral(p, byAccountId.get(p.referrer), new Date().toISOString().slice(0, 10));
res.json(result);
});
The client shows taps at once and takes the server’s numbers when each batch returns:
const tg = window.Telegram.WebApp;
const auth = { 'Content-Type': 'application/json', Authorization: 'tma ' + tg.initData };
let state = null;
let seq = 0;
let pending = 0;
let intervals = [];
let lastTap = 0;
let batch = null;
let busy = false;
async function load() {
state = await (await fetch('/api/me', { headers: auth })).json();
seq = state.lastSeq;
render();
}
document.getElementById('coin').addEventListener('pointerdown', () => {
if (!state || state.energy - pending < 1) return;
const now = performance.now();
if (lastTap) intervals.push(Math.round(now - lastTap));
lastTap = now;
pending += 1;
tg.HapticFeedback.impactOccurred('light');
render();
});
async function flush() {
if (busy) return;
if (!batch) {
if (pending === 0) return;
batch = { seq: ++seq, taps: pending, intervals: intervals.splice(0, 200) };
pending = 0;
}
busy = true;
try {
const r = await fetch('/api/taps', { method: 'POST', headers: auth, body: JSON.stringify(batch) });
if (!r.ok) throw new Error('HTTP ' + r.status);
const result = await r.json();
state.points = result.points;
state.energy = result.energy;
batch = null;
} catch (e) {
console.warn('retrying batch', batch.seq, e.message);
} finally {
busy = false;
render();
}
}
function render() {
if (!state) return;
document.getElementById('points').textContent = state.points + pending * state.tapLevel;
document.getElementById('energy').textContent = state.energy - pending;
}
setInterval(flush, 2000);
load();
Loading lastSeq from /api/me first keeps sequence numbers rising after a reload. HapticFeedback.impactOccurred gives each tap a light buzz.
Catching autoclickers without banning fast humans
The cap already stops bots from out-earning a fast human. Detection finds accounts that play like machines, so you can keep them out of prizes. Useful signals:
- Timing that is too regular.
looksScripted()flags 30 or more taps whose gaps average under 40 ms or vary by less than 5%. People are irregular; a simple script is not. - Claims far above the cap. A real client never overshoots by much, so a large overshoot means an edited request.
- Sessions no person plays. Tapping at the cap for hours, around the clock.
- Many accounts, one source. One device or IP range behind a cluster of referrals. See how to stop multi-accounting and VPN abuse.
A smart script can add jitter, so treat each signal as a score, not a verdict. Review top accounts by hand, and withhold prizes and referral bonuses before you ban anyone.
Step 3: upgrades, passive income, referrals and leaderboards
Upgrades are the sink that gives points meaning: each level costs more, and the server checks and charges the balance in one step. Passive income is settled from timestamps like energy, capped above at 3 hours away so players come back. For curve design, virtual economy design covers sources, sinks and inflation.
const UPGRADES = {
tap: { field: 'tapLevel', base: 500, growth: 2 },
energy: { field: 'energyLevel', base: 400, growth: 1.8 },
};
function buyUpgrade(p, kind, nowMs) {
const u = UPGRADES[kind];
if (!u) return { ok: false, error: 'unknown upgrade' };
settle(p, nowMs);
const cost = Math.round(u.base * u.growth ** (p[u.field] - 1));
if (p.points < cost) return { ok: false, error: 'not enough points', cost };
p.points -= cost;
p[u.field] += 1;
return { ok: true, cost, level: p[u.field], points: p.points };
}
const REFERRAL = { minTaps: 2000, bonus: 2500, dailyCap: 20 };
function payReferral(invitee, referrer, today) {
if (invitee.referralPaid || invitee.taps < REFERRAL.minTaps) return false;
if (referrer.refDay !== today) {
referrer.refDay = today;
referrer.refCount = 0;
}
if (referrer.refCount >= REFERRAL.dailyCap) return false;
invitee.referralPaid = true;
referrer.refCount += 1;
referrer.points += REFERRAL.bonus;
return true;
}
- Referral links. Telegram passes a
startappvalue from a link such ashttps://t.me/botusername/appname?startapp=ref_42to the Mini App asstart_param, inside the signedinitData. Theauthmiddleware stores a referrer only when an account is created, and only if it exists. - Pay on real play, not on join. The bonus waits for 2,000 accepted taps by the invitee and is capped at 20 a day per referrer, so fake invites are worth far less.
- Leaderboards. Sorting all players per request is fine for a test. At scale, use a Redis sorted set: the Redis sorted sets docs show
ZADDandZINCRBYfor updating leaderboards andZREVRANGEandZREVRANKfor reading them. Leave flagged accounts out of anything with a prize.
Points, not tokens: the rules to check first
The fastest way to turn a clicker into a legal and platform problem is to promise that points will become a token. We read Telegram’s Bot Platform Developer Terms for this guide (checked on October 22, 2026). They do not mention tap games or airdrops, but these sections apply:
| Rule | What it says | What it means for a tap game |
|---|---|---|
| Section 6.2 | Transactions for digital goods and services must use Telegram Stars only | Selling energy refills, boosts or skins means selling them for Stars |
| Section 7.1 | Tokens or assets a Mini App creates and distributes must use the TON blockchain | A token on any other chain is out |
| Section 7.4 | No direct or indirect promotion of non-TON cryptoassets, and no linking users to platforms that promote them | Check every partner, task and offer you show |
| Section 5.2 | No misleading users into actions in pursuit of an unachievable outcome; MLM and ponzi schemes are named examples | “Tap now, get rich at listing” is exactly the risk |
Law can apply on top of the platform rules. The UK’s Financial Conduct Authority says its cryptoasset financial promotions regime has applied since October 8, 2023 to firms marketing cryptoassets to UK consumers, including firms based overseas, covers websites, apps and social media, and that breaking it is a criminal offense. Securities and consumer protection rules can apply elsewhere, depending on the token. If you still want one, talk to a lawyer before you mention it in the game.
Do not do this
Do not hint at a future airdrop, a listing date or a points-to-token ratio. Do not call points “mining” or show them in dollars. Do not let players buy, sell or transfer points for money. Each of these turns a game balance into something players reasonably see as an investment.
Virtual points with clear terms
Say in your terms that points have no cash value, cannot be sold or transferred, are spent only in the game, and can be taken back if earned by cheating or from a reversed offer. Show that near the coin too. Terms of service for rewards sites has wording to adapt.
How a tap game makes money
The taps earn nothing by themselves; they bring players back every day. Revenue comes from what sits around the coin:
| Rewarded ads | Stars purchases | Offerwall | |
|---|---|---|---|
| Who pays | Advertisers, per view | Players, in Stars | Advertisers, per completed offer |
| Player gets | An energy refill or boost per video | Boosts, refills or skins | Points for each offer they finish |
| Fits | Every player, many times a day | Fans who want to skip waiting | Players who want a big jump in points |
| Setup | An ad SDK made for Mini Apps | createInvoiceLink with XTR and openInvoice | A signed link and a postback handler |
- Rewarded ads. A full energy refill for one video, a few times a day. AdsGram alternatives compares ad options for Mini Apps, and offerwall vs rewarded video shows how the formats combine.
- Stars. The Bot API says to pass
XTRas the currency and an emptyprovider_tokenfor payments in Stars, and the Mini App opens the invoice link withTelegram.WebApp.openInvoice. Telegram’s Stars payments guide says to answer the pre-checkout query within 10 seconds and to deliver the item only after thesuccessful_paymentmessage arrives. Telegram Stars vs offerwall covers what a Star is worth to you. - Offerwall. Your server signs a link for the player’s account ID, the Mini App opens it with
openLink, and points arrive by signed postback, as in Add an offerwall to a Telegram Mini App.
The game server needs two more routes. The link route follows the Offerwall link docs:
app.get('/api/offerwall-link', auth, (req, res) => {
const userId = req.player.id;
const hash = crypto.createHmac('sha256', process.env.SHARKLIO_LINK_SALT).update(String(userId)).digest('hex');
res.json({ url: `https://wall.sharklio.com/${process.env.SHARKLIO_APP_ID}?user_id=${encodeURIComponent(userId)}&hash=${hash}` });
});
The postback route is the Node.js handler from the Postbacks docs: verify the hash, add the reward on status 1 once per transaction ID, and take it back on status 2 only if you credited it. The Node.js postback guide and preventing duplicate reward credits have the details. To price points per dollar, try the virtual currency calculator.
Step 4: test the server logic before launch
Every block above ran on Node 22 with the built-in node:test runner and Express 4.21. What the tests cover:
- initData: a valid string passes; a changed name, a wrong bot token, an
auth_datetwo hours old and an empty string all fail. - Rate cap: 500 claimed taps after 2 seconds are cut to 24 and flagged, and ten batches sent 100 ms apart cannot beat the cap. Never give each batch a minimum window: at 1 second, ten batches a second would earn ten times the cap.
- Energy: 5 energy plus 10 seconds of waiting buys exactly 15 taps, and an hour away still allows only 10 seconds of taps.
- Passive income: syncs every 3 seconds for an hour add exactly 100 points at 100 per hour. Rounding down on each sync would add nothing, hence the
carry. - Retries: a batch that fails once and is resent with the same
seqis counted once. - Postbacks: a bad hash gets 403, a repeated credit is ignored, a reversal of a never-credited
test_transaction changes nothing, and a real reversal takes points back.
Then test in Telegram on Android, iOS and desktop, and use Send a test postback in Integration status on your app’s Integration tab. The Testing and troubleshooting docs list the other checks.
An offerwall that pays your game’s points
Sharklio is a pay-per-result affiliate network. In a tap game, its offerwall opens from one signed link per player, shows offers in your own currency name and icon at the rate and user split you set, and sends a signed postback for every credit and every reversal, so points only ever change on your server. Offers depend on the player’s country and device, players on VPNs, proxies, Tor or data center networks are blocked from the wall, and players must be at least 16. Every publisher application is reviewed by hand, and once your account is approved, each app you add is live right away. See how the wall works or read how to get approved as a publisher.
Frequently asked questions
How do tap-to-earn games make money?
From what surrounds the taps: rewarded ads for energy refills, Stars purchases for boosts, and offerwalls where advertisers pay for completed offers and players get points. The taps bring players back; they do not earn money on their own.
How do I stop autoclickers in a Telegram clicker game?
Count taps on the server, cap them at what a fast human can do per second, and limit them by server-computed energy. Then flag accounts with machine-regular timing or impossible claims and keep them out of prizes.
Can I make a Hamster Kombat clone?
You can build the same mechanics: tapping, energy, upgrades, passive income and referrals. Do not copy the name or art, and do not copy the token airdrop promise, which raises Telegram rule and legal issues.
Can a Telegram tap game give out crypto tokens?
Telegram’s developer terms require tokens a Mini App distributes to use the TON blockchain and forbid promoting non-TON cryptoassets. Laws such as the UK’s cryptoasset promotion rules can apply too, so get legal advice first.
Do I need Telegram Stars to sell boosts?
Yes. Section 6.2 of Telegram’s Bot Platform Developer Terms says digital goods and services in bots and Mini Apps must be paid with Stars.