Honest disclaimer upfront: most of the code was written with Claude (AI). I'm not a professional developer — more like someone who knows how to describe what they want and understand what comes back.
The idea was simple: a block puzzle game that lives inside Telegram. No download, no install — just tap and play. Two evenings for the core mechanics, eight more days to make it not embarrassing to show people.
Stack — deliberately minimal
- One HTML file (~380KB) — no React, no bundlers, vanilla JS
- Cloudflare Workers — backend, Telegram initData verification
- D1 (SQLite on the edge) — leaderboard and Battle Pass
- KV — leaderboard cache
Telegram Mini App is just a webpage. Pulling in a framework felt like overkill. Everything — game logic, animations, UI — lives in one file, 7600 lines of vanilla JS.
Verifying Telegram initData
First thing you need on the backend: prove the request actually came from Telegram and not someone who found your API endpoint.
Telegram signs initData with HMAC-SHA256. The verification:
async function verifyInitData(initData, botToken) {
const params = new URLSearchParams(initData);
const hash = params.get('hash');
params.delete('hash');
// Key = HMAC("WebAppData", botToken)
const secretKey = await crypto.subtle.importKey(
'raw', new TextEncoder().encode('WebAppData'),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const derivedKey = await crypto.subtle.sign(
'HMAC', secretKey,
new TextEncoder().encode(botToken)
);
const verifyKey = await crypto.subtle.importKey(
'raw', derivedKey,
{ name: 'HMAC', hash: 'SHA-256' }, false, ['verify']
);
// Check string — keys sorted, one per line
const checkString = [...params.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => k + '=' + v)
.join('\n');
return crypto.subtle.verify(
'HMAC', verifyKey,
hexToBytes(hash),
new TextEncoder().encode(checkString)
);
}
Without this, anyone can send arbitrary scores to your /score endpoint. I built the backend without it first — added it later. Don't repeat that mistake.
Performance: where it actually hurt
CSS variables + rAF = jank on budget Android
When I said "the game freezes on Snapdragon 460 when crystal blocks appear" — AI found the cause I would have spent days hunting.
The animation used hue-rotate(var(--crystal-hue)) on a CSS variable updated every frame via rAF:
// Before: every frame → style recalculation on main thread
function animStep(ts) {
_hueDeg = (_hueDeg + 0.06 * dt) % 360;
document.documentElement.style.setProperty('--crystal-hue', _hueDeg);
requestAnimationFrame(animStep);
}
Every CSS variable change triggers style recalculation for all elements using it. On a 64-cell grid — 64 style recalculations per frame, on the main thread.
Fix — back to pure CSS @keyframes:
/* After: runs on GPU compositor thread, main thread untouched */
@keyframes crystalShimmer {
0% { filter: hue-rotate(0deg) brightness(1.1); }
50% { filter: hue-rotate(180deg) brightness(1.3); }
100% { filter: hue-rotate(360deg) brightness(1.1); }
}
.crystal-cell {
animation: crystalShimmer 3s linear infinite;
}
CSS animations on filter run on the compositor thread — main thread not involved at all.
Object Pools: zero allocations during line clears
Clearing 2 lines simultaneously spawns 80+ particles. Without pooling that's 80 createElement calls + 80 timers + GC spike = 16-32ms freeze at the most satisfying moment of the game.
// Before: create and destroy per particle
function spawnParticle(x, y, color) {
const p = document.createElement('div'); // GC pressure
document.body.appendChild(p);
setTimeout(() => p.remove(), 800); // 80 concurrent timers
}
// After: take from pool, return when done
const _ptPool = [];
let _ptAlive = 0;
const PT_MAX = 120;
function spawnParticle(x, y, color) {
if (_ptAlive >= PT_MAX) return;
const p = _ptPool.pop() || document.createElement('div');
p.className = 'pt';
p.style.cssText = 'left:' + x + 'px;top:' + y + 'px;background:' + color;
fxLayer.appendChild(p);
_ptAlive++;
setTimeout(() => {
fxLayer.removeChild(p);
p.style.cssText = '';
p.className = '';
_ptAlive--;
_ptPool.push(p); // return to pool
}, 800);
}
After warmup (first 2-3 clears) — zero allocations. GC stays asleep. Same pattern for score pop-ups, combo banners, diamond particles — separate pool for each type.
D1 + KV architecture
Leaderboard request:
KV hit (~5ms) → serve from cache
KV miss → D1 SELECT (80-300ms) → write to KV for 3 min
bust=timestamp → skip KV, go to D1 (after new record)
D1 is source of truth. Complex queries, sorting. Cold request latency from Europe: 80-300ms.
KV is hot cache. Simple get/put, ~5ms, TTL built-in.
Bust parameter for cache invalidation after a new record:
// Client: after successful score submit, set flag
_boardForceRefresh[modeKey] = true;
// Next leaderboard request adds bust
const bustParam = _boardForceRefresh[m] ? '&bust=' + Date.now() : '';
const url = API + '/leaderboard?mode=' + m + '&limit=50' + bustParam;
delete _boardForceRefresh[m];
// Worker: bust skips KV, goes straight to D1
const skipCache = !!url.searchParams.get('bust');
if (!skipCache) {
const cached = await env.BFM_KV.get(cacheKey, 'json');
if (cached) { board = cached; fromCache = true; }
}
if (!board) {
const rows = await env.BFM_DB.prepare(
'SELECT * FROM leaderboard WHERE mode=? ORDER BY score DESC LIMIT ?'
).bind(mode, limit).all();
board = rows.results.map((e, i) => ({ ...e, rank: i + 1 }));
env.BFM_KV.put(cacheKey, JSON.stringify(board), { expirationTtl: 180 });
}
Shadow-banning cheaters
Regular ban — cheater knows they're caught and looks for a workaround. Shadow ban — they think everything works, but they never appear in the real leaderboard.
const MAX_SCORE = {
classic: 5_000_000,
mini: 3_000_000,
speed: 2_000_000
};
if (score > MAX_SCORE[mode]) {
// Write to separate table
await env.BFM_DB.prepare(
'INSERT INTO suspicious_scores(tg_id, mode, score, ts) VALUES(?,?,?,?)'
).bind(tg_id, mode, score, Date.now()).run();
// Return success — cheater doesn't know they're caught
return json({ success: true, is_new_record: false });
// Never touches the real leaderboard
}
First week caught several attempts with scores like 99999999. Classic.
The D1 write problem: 800 writes/day from one user
Noticed an anomaly in the Cloudflare dashboard — 801 write requests in a day, peak ~140 at once. Broke it down:
| Operation | Writes |
|---|---|
saveScore: INSERT users + INSERT leaderboard + UPDATE lastSeen |
3 |
addBPXP: INSERT OR IGNORE + UPDATE bp_xp |
2 |
trackEvent from addBPXP |
1 |
trackEvent from saveScore |
1 |
POST /event from client |
1 |
| Total | 9 writes/game |
Problem: trackEvent was being called three times per game — duplication across different code paths.
// Before: 3 INSERT INTO events per game
// In addBPXP:
trackEvent(env, tg_id, 'game_end', mode, score).catch(() => {});
// In saveScore:
trackEvent(env, tg_id, 'score_submit', mode, score).catch(() => {});
// + client sends POST /event with 'game_over'
// After: only one event from the client
// Removed trackEvent from addBPXP and saveScore
// -2 writes/game
Also updateLastSeen was doing a separate UPDATE even when isNewRecord=true — where updated=? was already written in the INSERT:
// Before: unnecessary UPDATE every game
await updateLastSeen(env, tg_id); // always
// After: only when not a new record (+ 5-min throttle)
if (isNewRecord) {
// updated already written in INSERT above
env.BFM_KV.delete('lb:' + mode).catch(() => {});
} else {
await updateLastSeen(env, tg_id, user?.updated); // throttled
}
Results:
| Metric | Before | After |
|---|---|---|
| Writes per game | ~9 | ~5 |
| D1 requests/day (1 user) | ~800 | ~200 |
| Repeat load time | ~8 sec | instant |
| Freeze during line clear | 16-32ms | <2ms |
Where AI actually helped
Performance diagnosis. Describe the symptom, get the cause with explanation. The CSS variable + rAF issue — I'd have hunted that for days on my own.
Architecture decisions. Bust parameter for KV invalidation, object pools, D1/KV split — all iterated through conversation.
Security. initData verification, shadow banning — it suggested these when I described what I wanted to protect.
Where AI didn't help
Balance and feel. "Is the combo system satisfying?" — it said yes. Useless. You have to feel it yourself.
Visual. "Make it look nice" doesn't work. You need specifics: "the white outline is duplicating inside the cell because of inset box-shadow, remove it and keep only the external glow."
API integrations. Telegram CloudStorage API, WebApp event subscriptions — AI frequently confused API versions or invented non-existent methods. You still need to read the docs.
The most surprising thing
The leaderboard. Added it almost as an afterthought on the last day. It's what keeps people coming back — friends checking daily to see if someone overtook them.
And: removing Cache-Control: no-cache, no-store from the GitHub Pages _headers file dropped repeat load time from ~8 seconds to instant. The browser was re-downloading 380KB every single open because I had put no-cache there "just in case."
What's next
- Server-side seed for daily mode (currently everyone gets different pieces — unfair for competition)
- Server-side boost inventory (currently localStorage — readable via DevTools in 10 seconds)
- Analytics Engine instead of D1 for events (D1 for analytics is overkill, AE is made for this)
If you've built anything on Cloudflare D1/Workers or optimized Canvas on mobile — curious to hear about your experience in the comments.
Try the game: @BlockFastManiaBot
Top comments (1)
Happy to answer any questions about the Cloudflare D1/KV
setup or Telegram Mini App architecture!