Building games is one of the best ways to combine creativity with software engineering.
As part of my Web3 + AI Internship at Ether Authority and SecureChain.ai, I wanted to build something that wasn't just functional but also engaging and enjoyable to play. That led to Surf Rush—a modern Telegram Mini App endless runner developed using React, TypeScript, HTML5 Canvas, and Vite.
This project challenged me to design smooth gameplay mechanics, optimize rendering performance, build a rewarding progression system, integrate Telegram features, and create a polished user experience that works seamlessly across desktop and mobile devices.
In this article, I'll walk through the complete development journey—from concept and architecture to deployment, performance optimizations, challenges, and lessons learned.
Surf Rush: Engineering a Production-Ready Telegram Mini App Game with React, TypeScript & HTML5 Canvas
A senior engineer's complete breakdown of building an endless runner with XP progression, power-ups, daily missions, an on-chain reward system, and Telegram-native social features — from blank canvas to live product.
Table of Contents
- Why This Project Exists
- What Surf Rush Actually Is
- Technology Stack & Why Each Choice Was Made
- Architecture Deep Dive
- The HTML5 Canvas Game Engine
- React Component System
- TypeScript as the Project's Backbone
- XP, Leveling & the Progression Economy
- Coin System & Reward Store
- Daily Missions
- Shield & Magnet Power-Ups
- Leaderboard Architecture
- Telegram Mini App Integration
- Web3 Foundation: SurfRushRewards Smart Contract
- Bugs I Hit and How I Fixed Them
- Performance Engineering
- Responsive Design for a 390px Universe
- Lessons Learned
- Roadmap
- Links & Resources
Why This Project Exists
Most portfolio projects are technical exercises that live on URLs nobody visits. They demonstrate skill in isolation but don't answer the question any serious engineer should be able to answer: can you build something that real people will actually use?
I wanted to build something at the intersection of three spaces I find genuinely interesting: real-time interactive experiences, progressive engagement systems, and social-native distribution. The Telegram Mini App platform is one of the few places where all three converge without requiring a full-stack backend, an app store submission, or a marketing budget.
Telegram has over 900 million monthly active users. A Mini App runs inside a chat that people already have open, meaning distribution is structural — not something you bolt on after launch. A game that spreads inside Telegram spreads inside Telegram's network, which is fundamentally different from hoping someone clicks a link.
Beyond distribution, I had specific engineering questions I wanted to answer empirically:
- Can a React app sustain a 60fps game loop without frame drops on mid-range mobile hardware?
- What does a well-designed XP and daily mission system look like at the code level — not just the feature level?
- Where exactly does the Telegram Web App SDK break, and what are the correct defensive patterns around it?
- How do you architect a browser game so the engine is completely decoupled from the UI framework?
Surf Rush is my empirical answer to all of those questions, built under real constraints and shipped to a live URL.
What Surf Rush Actually Is
Surf Rush is an endless runner where the player controls a surfer navigating a procedurally generated ocean course split into three lanes. The surfer moves left and right to avoid incoming obstacles — rocks, sharks, jellyfish, and rogue waves — while collecting coins and mystery boxes scattered along the course.
The core gameplay loop:
Start → Dodge obstacles → Collect coins/power-ups → Die or complete a run
→ Earn XP and coins → Level up → Check missions → Visit Reward Store
→ Share score on Telegram → Start next run
The loop is simple at its core — survive as long as possible — but every layer wrapped around it is designed to answer the retention question: why come back tomorrow? The answer is missions that reset daily, an XP system that rewards consistency, and a Reward Store that creates something to save toward.
Try it yourself: surf-rush-game.vercel.app
Technology Stack
Choosing a stack isn't about picking favorites. It's about matching tools to constraints. The constraints here were: mobile-first, 60fps game loop, Telegram integration, TypeScript everywhere, and zero backend dependency for the initial ship.
React 18
React handles everything that isn't the game: the main menu, HUD, post-run stats screen, leaderboard, store, missions panel, and profile view. The key insight is that React and a game loop are fundamentally different models — React renders declaratively on state changes, while a game loop updates imperatively on every frame tick. These models coexist well as long as they never cross the boundary. React manages the canvas element's lifecycle (mount, unmount, resize); the game engine manages everything inside it.
TypeScript (strict mode)
TypeScript is non-negotiable on a project with this many intersecting systems. The game state object, player data model, power-up types, mission definitions, store items, and Telegram SDK types are all strictly typed. A type error caught at compile time in a game loop is worth ten runtime crashes discovered by a user mid-run. The investment in type definitions pays dividends every time you refactor — which on a solo project with tight timelines happens constantly.
Vite
Vite's near-instant HMR (Hot Module Replacement) made iterating on game physics and UI simultaneously viable. The feedback loop — save a file, see the canvas update — ran in under a second. Combined with tsc --noEmit in watch mode, the developer experience was tight enough to maintain flow state during the build phase.
HTML5 Canvas (2D Context)
The game renderer uses the browser's native <canvas> element with the CanvasRenderingContext2D API. Choosing canvas over a DOM-based approach (CSS transforms, SVG) gives direct control over every pixel on every frame. There's no virtual DOM overhead in the render path, no layout recalculations, and no style recomputation — just direct draw calls at the speed of the browser's compositor.
Telegram Web App SDK (@twa-dev/sdk)
The Telegram SDK provides typed access to the WebApp JavaScript API, covering user identity, native share sheets, back button control, haptic feedback, and color scheme adaptation. The typed wrapper eliminates the window.Telegram.WebApp casting that would otherwise be required throughout the codebase.
Ethers.js v6 + Hardhat + Solidity
The project includes a Solidity smart contract (SurfRushRewards.sol) for on-chain score saving and reward claiming, with an Ethers.js v6 integration in the frontend for MetaMask wallet connection. This lays the groundwork for the TON/Web3 roadmap without blocking the initial browser-based launch.
Vercel
Deployment is a Vite static build deployed to Vercel. Build command: npm run build. Output: dist/. Zero configuration needed — Vite's build output is a perfectly valid static site.
Architecture Deep Dive
The most important architectural decision in Surf Rush was made before the first line of game code was written: the game engine and the React application are completely separate layers.
surf-rush/
├── src/
│ ├── components/ # React UI layer
│ │ ├── Game/
│ │ │ ├── GameCanvas.tsx # Canvas lifecycle + loop orchestration
│ │ │ ├── HUD.tsx # Score, coins, power-up timers
│ │ │ └── GameOver.tsx # Post-run panel + share
│ │ ├── Leaderboard/
│ │ ├── Store/
│ │ ├── Missions/
│ │ ├── Profile/
│ │ └── Shared/ # Buttons, modals, transitions
│ ├── engine/ # Pure TypeScript — zero React dependencies
│ │ ├── gameLoop.ts # rAF loop, timing, pause/resume
│ │ ├── physics.ts # Velocity, acceleration, collision boxes
│ │ ├── renderer.ts # All canvas draw calls
│ │ ├── collisions.ts # AABB detection + response
│ │ ├── entities.ts # Player, Obstacle, Coin, PowerUp types
│ │ └── procedural.ts # Obstacle & coin placement generation
│ ├── hooks/
│ │ ├── useGameState.ts # React bridge to engine state
│ │ ├── usePlayerData.ts # XP, coins, level — localStorage
│ │ └── useTelegram.ts # SDK initialization + guard wrapper
│ ├── store/
│ │ └── playerStore.ts # In-memory + localStorage persistence
│ ├── types/
│ │ └── index.ts # All shared interfaces + enums
│ └── utils/
│ ├── xp.ts # Level curve calculations
│ ├── missions.ts # Daily reset logic
│ └── constants.ts # Magic numbers centralized
The engine/ directory contains zero imports from React. It's a standalone TypeScript module that could run in Node.js, a Web Worker, or a test runner. The React layer communicates with it through a thin interface defined in useGameState.ts — a custom hook that starts the loop, subscribes to state changes, and surfaces readable state to the component tree.
Component Hierarchy
<App>
├── <TelegramProvider> // SDK init, readiness gate, user identity
└── <Router> // Screen-level navigation state machine
├── <MainMenu />
├── <Game>
│ ├── <GameCanvas /> // useRef → <canvas>, owns loop lifecycle
│ ├── <HUD /> // Overlaid React UI
│ └── <GameOver />
├── <Leaderboard />
├── <RewardStore />
├── <Missions />
└── <Profile />
The <Router> is not a URL router — it's a state machine with a screen enum that drives which top-level component renders. Telegram Mini Apps don't have URLs in the traditional sense, so react-router-dom would add overhead with no benefit.
The HTML5 Canvas Game Engine
The Game Loop
The game loop is the heart of every real-time interactive experience. Surf Rush's loop uses requestAnimationFrame to target 60fps, with delta-time calculations to keep physics frame-rate independent.
// engine/gameLoop.ts
type LoopCallback = (deltaTime: number) => void;
export class GameLoop {
private rafId: number = 0;
private lastTimestamp: number = 0;
private paused: boolean = false;
constructor(private readonly onTick: LoopCallback) {}
start(): void {
this.lastTimestamp = performance.now();
this.rafId = requestAnimationFrame(this.tick);
}
stop(): void {
cancelAnimationFrame(this.rafId);
}
private tick = (timestamp: number): void => {
if (this.paused) {
this.rafId = requestAnimationFrame(this.tick);
return;
}
const deltaTime = Math.min((timestamp - this.lastTimestamp) / 1000, 0.05);
this.lastTimestamp = timestamp;
this.onTick(deltaTime);
this.rafId = requestAnimationFrame(this.tick);
};
}
The Math.min(..., 0.05) clamp on delta time is critical. When the browser tab loses focus and regains it, deltaTime can spike to several seconds, which would teleport the player through the entire course. Clamping at 50ms (equivalent to ~20fps) prevents physics explosions on tab-switch.
Entity System
Game entities — the player, obstacles, coins, and power-ups — are plain TypeScript objects with no inheritance hierarchy. Composition over inheritance keeps the entity system flat and easy to reason about.
// types/index.ts
export interface Vector2D {
x: number;
y: number;
}
export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
export interface PlayerEntity {
position: Vector2D;
lane: 0 | 1 | 2;
bounds: BoundingBox;
isTransitioning: boolean;
transitionProgress: number;
}
export interface ObstacleEntity {
id: string;
type: 'rock' | 'shark' | 'jellyfish' | 'wave';
position: Vector2D;
bounds: BoundingBox;
speed: number;
}
export interface CoinEntity {
id: string;
position: Vector2D;
collected: boolean;
bobOffset: number; // for sinusoidal animation
}
export type PowerUpType = 'shield' | 'magnet' | 'speedBoost' | 'comboMultiplier';
export interface PowerUpEntity {
id: string;
type: PowerUpType;
position: Vector2D;
active: boolean;
expiresAt: number | null; // timestamp; null = not yet collected
}
Collision Detection
Surf Rush uses Axis-Aligned Bounding Box (AABB) collision detection. Each entity has a bounds object that's slightly smaller than its visual sprite — "hitbox shrinking" by about 20% makes the game feel fair without looking wrong.
// engine/collisions.ts
export const aabbIntersects = (a: BoundingBox, b: BoundingBox): boolean => {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
};
export const checkPlayerCollisions = (
player: PlayerEntity,
obstacles: ObstacleEntity[],
state: GameState
): CollisionResult => {
for (const obstacle of obstacles) {
if (aabbIntersects(player.bounds, obstacle.bounds)) {
if (state.shieldActive) {
return { type: 'shieldAbsorbed', obstacle };
}
return { type: 'lethal', obstacle };
}
}
return { type: 'none' };
};
Procedural Obstacle Placement
Obstacles are generated ahead of the player using a weighted randomization system. The generator maintains a minimum gap between consecutive obstacles scaled to the current game speed, preventing impossible configurations at high speeds.
// engine/procedural.ts
const OBSTACLE_WEIGHTS: Record<ObstacleEntity['type'], number> = {
rock: 0.4,
shark: 0.3,
jellyfish: 0.2,
wave: 0.1,
};
export const generateObstacle = (
speed: number,
canvasWidth: number,
canvasHeight: number
): ObstacleEntity => {
const lane = Math.floor(Math.random() * 3) as 0 | 1 | 2;
const type = weightedRandom(OBSTACLE_WEIGHTS);
const spawnX = canvasWidth + 50;
return {
id: crypto.randomUUID(),
type,
position: { x: spawnX, y: laneToY(lane, canvasHeight) },
bounds: obstacleHitbox(type),
speed,
};
};
React Component System
GameCanvas: The Bridge Between Worlds
GameCanvas is the most architecturally interesting component in the project. It holds the <canvas> element via a useRef, owns the GameLoop instance, and mediates between the imperative game engine and the declarative React tree.
// components/Game/GameCanvas.tsx
export const GameCanvas: React.FC<GameCanvasProps> = ({ onGameOver }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const loopRef = useRef<GameLoop | null>(null);
const stateRef = useRef<GameState>(initialGameState());
useLayoutEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Sync canvas internal resolution with CSS display size
const resizeObserver = new ResizeObserver(() => {
canvas.width = canvas.offsetWidth * window.devicePixelRatio;
canvas.height = canvas.offsetHeight * window.devicePixelRatio;
const ctx = canvas.getContext('2d');
if (ctx) ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
});
resizeObserver.observe(canvas);
const loop = new GameLoop((dt) => {
updateGameState(stateRef.current, dt);
renderFrame(canvas, stateRef.current);
if (stateRef.current.status === 'gameover') {
loop.stop();
onGameOver(stateRef.current.score, stateRef.current.coins);
}
});
loopRef.current = loop;
loop.start();
return () => {
loop.stop();
resizeObserver.disconnect();
};
}, []);
return (
<canvas
ref={canvasRef}
style={{ width: '100%', height: '100%', display: 'block' }}
aria-label="Surf Rush game canvas"
/>
);
};
The useLayoutEffect (not useEffect) is deliberate. It runs synchronously after DOM mutations and before the browser paints, ensuring that canvas.offsetWidth and canvas.offsetHeight return actual layout values rather than zero. This was the fix for the most frustrating early bug — detailed in the challenges section.
The devicePixelRatio scaling ensures crisp rendering on retina/HiDPI displays. Without it, canvas content looks blurry on modern mobile screens because the canvas's internal pixel grid doesn't match the display's physical pixel grid.
TypeScript as the Project's Backbone
TypeScript's value multiplies with project complexity. In Surf Rush, the game state object touches nearly every system in the codebase. Without strict typing, a refactor of the state shape would require manually tracking down every consumer. With TypeScript, the compiler does that work.
// types/index.ts
export interface GameState {
status: 'idle' | 'running' | 'paused' | 'gameover';
score: number;
distanceTraveled: number;
speed: number; // px/second, increases over time
coins: number; // session coins (not all-time balance)
xpEarned: number;
shieldActive: boolean;
magnetActive: boolean;
magnetExpiresAt: number; // ms timestamp
player: PlayerEntity;
obstacles: ObstacleEntity[];
coinEntities: CoinEntity[];
powerUps: PowerUpEntity[];
particles: ParticleEntity[];
frameCount: number;
}
export interface PlayerProfile {
totalCoins: number; // all-time wallet balance
totalXP: number;
level: number;
highScore: number;
purchasedItems: string[]; // store item IDs
equippedSkin: string;
missionProgress: MissionProgress[];
lastMissionReset: string; // ISO date string
leaderboardEntries: LeaderboardEntry[];
}
Union type for status means exhaustive switch statements in the renderer and update logic are type-checked. If a new status is added, every unhandled case becomes a compile error.
XP, Leveling & the Progression Economy
The XP system is designed around a diminishing-returns curve: early levels feel fast (rewarding the new player), later levels require more investment (creating long-term goals). The formula is an exponential with tunable constants:
// utils/xp.ts
const BASE_XP = 100;
const EXPONENT = 1.4;
export const xpForLevel = (level: number): number =>
Math.floor(BASE_XP * Math.pow(level, EXPONENT));
export const levelForXP = (totalXP: number): number => {
let level = 1;
let accumulated = 0;
while (accumulated + xpForLevel(level) <= totalXP) {
accumulated += xpForLevel(level);
level++;
}
return level;
};
export const xpProgressInCurrentLevel = (totalXP: number): {
current: number;
required: number;
percent: number;
} => {
const level = levelForXP(totalXP);
let accumulated = 0;
for (let l = 1; l < level; l++) accumulated += xpForLevel(l);
const current = totalXP - accumulated;
const required = xpForLevel(level);
return { current, required, percent: current / required };
};
With EXPONENT = 1.4, Level 1 requires 100 XP, Level 10 requires ~251 XP, and Level 20 requires ~536 XP. The curve feels forgiving in the first few sessions and increasingly meaningful after that.
XP is awarded at run-end based on distance traveled plus a coin multiplier:
export const calculateRunXP = (distance: number, coins: number): number => {
const distanceXP = Math.floor(distance / 10);
const coinXP = Math.floor(coins * 0.5);
return distanceXP + coinXP;
};
Every level-up triggers a full-screen celebration animation (CSS keyframe scale + opacity pulse) and unlocks items in the Reward Store gated behind level thresholds.
Coin System & Reward Store
Coins serve as the game's primary currency, creating a loop that gives individual runs meaning beyond just the score.
Coin sources:
- In-run collection (base value: 1 per coin)
- Daily mission completion bonuses (50–200 coins per mission)
- Level-up bonuses (scaling with level)
Coin sinks:
- Reward Store: board skins, trail effects, character variants
The store items are defined as static data:
// utils/constants.ts
export const STORE_ITEMS: StoreItem[] = [
{ id: 'skin_classic', name: 'Classic Board', cost: 0, levelRequired: 1, category: 'skin' },
{ id: 'skin_neon', name: 'Neon Rider', cost: 500, levelRequired: 3, category: 'skin' },
{ id: 'skin_fire', name: 'Fire Surfer', cost: 1200, levelRequired: 7, category: 'skin' },
{ id: 'trail_bubbles', name: 'Bubble Trail', cost: 300, levelRequired: 2, category: 'trail' },
{ id: 'trail_lightning', name: 'Lightning Streak', cost: 800, levelRequired: 5, category: 'trail' },
{ id: 'char_pirate', name: 'Pirate Surfer', cost: 2000, levelRequired: 10, category: 'character' },
];
Level requirements on store items serve dual purpose: they gate content to maintain novelty at higher levels, and they give low-level players a visible target to work toward.
Daily Missions
Three missions regenerate every calendar day. The reset logic checks against a stored ISO date string rather than a 24-hour timestamp, which means missions reset at midnight local time — the natural expectation for a "daily" feature.
// utils/missions.ts
export const checkDailyReset = (lastReset: string): boolean => {
const now = new Date();
const last = new Date(lastReset);
return (
now.getDate() !== last.getDate() ||
now.getMonth() !== last.getMonth() ||
now.getFullYear() !== last.getFullYear()
);
};
export const MISSION_TEMPLATES: MissionTemplate[] = [
{ id: 'collect_coins', description: 'Collect {target} coins in a single run', targets: [25, 50, 100], reward: 75 },
{ id: 'survive_time', description: 'Survive for {target} seconds', targets: [30, 60, 90], reward: 100 },
{ id: 'use_powerup', description: 'Activate a power-up {target} times', targets: [1, 3, 5], reward: 50 },
{ id: 'reach_distance', description: 'Travel {target} meters in one run', targets: [200, 500, 1000], reward: 125 },
];
export const generateDailyMissions = (): Mission[] => {
// Deterministic per calendar day — same player gets same missions
const seed = new Date().toISOString().split('T')[0];
const rng = seededRandom(seed);
const shuffled = [...MISSION_TEMPLATES].sort(() => rng() - 0.5);
return shuffled.slice(0, 3).map((template) => ({
...template,
target: template.targets[Math.floor(rng() * template.targets.length)],
progress: 0,
completed: false,
}));
};
The deterministic generation based on the calendar date means all players get the same three missions each day — a subtle social feature that creates shared experiences without requiring a backend.
Shield & Magnet Power-Ups
Power-ups appear in the game world as glowing mystery boxes. Collecting one randomly activates one of four effects: Shield, Magnet, Speed Boost, or Combo Multiplier (negative effects — coin cuts, freezes — can also appear in harder difficulty tiers).
Shield
The Shield absorbs the next lethal collision. Implementation is a boolean flag in game state with a visual aura rendered around the player sprite:
// engine/renderer.ts — shield aura
const drawShieldAura = (ctx: CanvasRenderingContext2D, player: PlayerEntity) => {
const { x, y } = player.position;
const gradient = ctx.createRadialGradient(x, y, 10, x, y, 45);
gradient.addColorStop(0, 'rgba(100, 200, 255, 0.0)');
gradient.addColorStop(0.7, 'rgba(100, 200, 255, 0.3)');
gradient.addColorStop(1, 'rgba(100, 200, 255, 0.8)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(x, y, 45, 0, Math.PI * 2);
ctx.fill();
};
Magnet
The Magnet is the mechanically richer power-up. For 8 seconds, all coins within a radius are pulled toward the player using linear interpolation. The challenge: running proximity calculations on every coin every frame at 60fps can saturate mid-range mobile CPUs.
The naive approach:
// O(n) per frame — fine for small n, costly for large coin clusters
if (state.magnetActive) {
state.coinEntities.forEach((coin) => {
const dx = player.x - coin.position.x;
const dy = player.y - coin.position.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MAGNET_RADIUS) {
coin.position.x += dx * MAGNET_PULL_STRENGTH;
coin.position.y += dy * MAGNET_PULL_STRENGTH;
}
});
}
The optimized approach using spatial grid bucketing (details in the Performance section):
// Only check coins in adjacent grid cells — ~80% fewer comparisons
if (state.magnetActive) {
const nearbyCoins = spatialGrid.query(player.position, MAGNET_RADIUS);
nearbyCoins.forEach((coin) => {
const dx = player.position.x - coin.position.x;
const dy = player.position.y - coin.position.y;
coin.position.x += dx * MAGNET_PULL_STRENGTH;
coin.position.y += dy * MAGNET_PULL_STRENGTH;
});
}
Leaderboard Architecture
The leaderboard is stored in localStorage as a sorted array of entries, capped at the top 10 scores. This decision was deliberate for the MVP: zero backend, zero latency, always available offline.
// store/playerStore.ts
export interface LeaderboardEntry {
playerName: string; // Telegram username if available, else 'Player'
score: number;
coins: number;
timestamp: string; // ISO date
}
export const saveLeaderboardEntry = (entry: LeaderboardEntry): void => {
const existing = getLeaderboard();
const updated = [...existing, entry]
.sort((a, b) => b.score - a.score)
.slice(0, 10);
localStorage.setItem('surfRushLeaderboard', JSON.stringify(updated));
};
The architecture is intentionally swappable. The saveLeaderboardEntry and getLeaderboard functions are the only two call sites in the codebase. Replacing the localStorage implementation with a Supabase fetch is a two-function change that touches zero UI code.
Telegram Mini App Integration
Telegram Mini App integration is deceptively complex. The SDK documentation has gaps, behavior differs across Telegram clients (iOS, Android, Desktop, Web), and there are initialization timing issues that are nearly impossible to reproduce in a browser.
The useTelegram Hook
All SDK interaction is centralized in a single custom hook. This is the most important architectural pattern in the Telegram integration — it means bugs are fixed in one place, guards are applied consistently, and the rest of the codebase is blissfully unaware of whether it's running inside Telegram or a browser.
// hooks/useTelegram.ts
export interface TelegramContext {
isReady: boolean;
isTelegram: boolean;
user: TelegramUser | null;
shareScore: (score: number) => void;
hapticImpact: (style: 'light' | 'medium' | 'heavy') => void;
setBackButtonVisible: (visible: boolean) => void;
}
export const useTelegram = (): TelegramContext => {
const [isReady, setIsReady] = useState(false);
const tg = window.Telegram?.WebApp ?? null;
const isTelegram = tg !== null;
useEffect(() => {
if (!tg) return;
tg.ready();
tg.expand();
setIsReady(true);
}, []);
const shareScore = useCallback((score: number) => {
if (!isReady || !tg) return;
const text = `I scored ${score} in Surf Rush! 🏄♂️ Can you beat me?`;
const url = `https://surf-rush-game.vercel.app`;
tg.openTelegramLink(
`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`
);
}, [isReady, tg]);
const hapticImpact = useCallback((style: 'light' | 'medium' | 'heavy') => {
tg?.HapticFeedback?.impactOccurred(style);
}, [tg]);
const setBackButtonVisible = useCallback((visible: boolean) => {
if (!tg) return;
visible ? tg.BackButton.show() : tg.BackButton.hide();
}, [tg]);
return {
isReady,
isTelegram,
user: tg?.initDataUnsafe?.user ?? null,
shareScore,
hapticImpact,
setBackButtonVisible,
};
};
The isReady guard is the single most important line in the Telegram integration. Without it, tapping the share button on a slow device before tg.ready() resolves results in silent failure — no error, no dialog, just nothing.
Haptic Feedback
On Telegram Mobile, the Haptic Feedback API adds physical reinforcement to key moments. Three events trigger haptics: coin collection (light), power-up activation (medium), and collision (heavy). These two lines of code are worth their weight in engagement:
hapticImpact('heavy'); // on lethal collision
hapticImpact('medium'); // on power-up collection
hapticImpact('light'); // on coin collection
Adaptive Color Scheme
Telegram exposes the user's theme colors via tg.themeParams. The game's UI adapts to these colors, ensuring consistent visual integration with the user's Telegram client:
useEffect(() => {
if (!tg?.themeParams) return;
const root = document.documentElement;
root.style.setProperty('--tg-bg', tg.themeParams.bg_color ?? '#1a1a2e');
root.style.setProperty('--tg-text', tg.themeParams.text_color ?? '#ffffff');
root.style.setProperty('--tg-button', tg.themeParams.button_color ?? '#2196f3');
}, [tg?.themeParams]);
Web3 Foundation: SurfRushRewards Smart Contract
Beyond the browser game, Surf Rush includes a Solidity smart contract (SurfRushRewards.sol) that provides the on-chain infrastructure for score saving and reward claiming.
// SurfRushRewards.sol (simplified)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SurfRushRewards {
mapping(address => uint256) public highScores;
mapping(address => uint256) public lastClaimTimestamp;
uint256 public constant CLAIM_COOLDOWN = 1 days;
uint256 public constant REWARD_AMOUNT = 0.001 ether;
event ScoreSaved(address indexed player, uint256 score);
event RewardClaimed(address indexed player, uint256 amount);
function saveScore(uint256 score) external {
if (score > highScores[msg.sender]) {
highScores[msg.sender] = score;
emit ScoreSaved(msg.sender, score);
}
}
function claimReward() external {
require(
block.timestamp >= lastClaimTimestamp[msg.sender] + CLAIM_COOLDOWN,
"Cooldown active"
);
require(address(this).balance >= REWARD_AMOUNT, "Insufficient funds");
lastClaimTimestamp[msg.sender] = block.timestamp;
(bool sent, ) = msg.sender.call{value: REWARD_AMOUNT}("");
require(sent, "Transfer failed");
emit RewardClaimed(msg.sender, REWARD_AMOUNT);
}
function deposit() external payable {}
receive() external payable {}
}
The frontend MetaMask integration uses Ethers.js v6:
// wallet.ts
export const connectWallet = async (): Promise<string> => {
if (!window.ethereum) throw new Error('MetaMask not installed');
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
return signer.getAddress();
};
export const saveScoreOnChain = async (score: number): Promise<void> => {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, signer);
const tx = await contract.saveScore(BigInt(score));
await tx.wait();
};
This is the scaffolding for the TON blockchain integration in the roadmap — the architecture is already in place.
Bugs I Hit and How I Fixed Them
Honest bug documentation is one of the most useful things a technical article can contain. Here are the six issues that cost the most time, and exactly how each was resolved.
Bug 1: The Blank Canvas
Symptom: The game canvas rendered as a blank white rectangle. The loop was running (confirmed via console logs), but no pixels were drawn.
Root cause: The canvas element's offsetWidth and offsetHeight were 0 when getContext('2d') was called. React's useEffect runs after paint — by that point, layout had completed and dimensions were available. But I was calling getContext inside the effect's initialization code, which ran synchronously before the layout pass.
Wait — that's backwards. The actual issue: I was originally calling canvas.width = canvas.offsetWidth inside useEffect, which should be fine. The real culprit was that the canvas had width="0" and height="0" as HTML attributes (Vite's default), which override CSS sizing. Canvas elements use their width/height HTML attributes for their internal resolution, not CSS.
Fix: Explicitly set canvas.width and canvas.height to offsetWidth and offsetHeight after mount, and switch to useLayoutEffect to ensure this happens before the first paint. Then add a ResizeObserver to keep them in sync when the viewport changes.
useLayoutEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
// Now getContext works correctly
}, []);
Time lost: ~4 hours, mostly to the wrong hypothesis about rendering context timing.
Bug 2: Telegram Share Silently Failing
Symptom: The share button after a run would do nothing on certain Telegram clients. No error, no dialog. Worked fine in Chrome desktop.
Root cause: window.Telegram.WebApp.ready() is asynchronous in its effects — calling Telegram APIs before the SDK is fully initialized fails silently. On faster devices, initialization completes before the share button is visible. On slower devices, it sometimes doesn't.
Fix: The isReady boolean in useTelegram guards every SDK call. The share button is also disabled (visually indicated) until isReady is true.
const shareScore = (score: number) => {
if (!isReady) return; // ← the guard
// ... share logic
};
Time lost: ~6 hours, including a long chase through Telegram's thin documentation.
Bug 3: Mobile Layout Broken on Real Devices
Symptom: Chrome DevTools mobile simulator looked fine. On a physical iPhone 13 and Samsung Galaxy A52, text overflowed containers, buttons were clipped by the iOS home indicator, and the canvas didn't fill the viewport.
Root causes (three separate issues):
- The canvas used
height: 100vhwhich doesn't account for the iOS Safari bottom bar and Telegram's UI chrome. - No
safe-area-insetpadding on the bottom navigation. - Fixed pixel font sizes that looked fine at 390px but broke at 360px (older Androids).
Fix: Three-part solution.
/* 1. Canvas uses dvh (dynamic viewport height) where supported */
.game-canvas-wrapper {
height: 100dvh;
height: 100vh; /* fallback */
}
/* 2. Safe area insets for iOS */
.bottom-nav {
padding-bottom: max(12px, env(safe-area-inset-bottom));
}
/* 3. Fluid typography with clamp() */
.hud-score {
font-size: clamp(14px, 3.5vw, 20px);
}
Time lost: ~3 hours of device testing.
Bug 4: Frame Drops During Magnet Power-Up
Symptom: The Magnet power-up caused visible stuttering (dropping to ~30fps) on low-end Android devices during dense coin layouts.
Root cause: The magnet loop iterated over every coin entity every frame at 60fps. With 40+ coins on screen simultaneously, this was ~2,400 distance calculations per second — enough to saturate a single-core JS thread on older hardware.
Fix: Spatial grid bucketing. The world is divided into a coarse grid of cells (each ~100px × 100px). On each frame, coins are assigned to cells based on their position. The magnet proximity check only queries cells adjacent to the player's cell, reducing the comparison set from ~40 to ~6–8 on average.
class SpatialGrid {
private cells: Map<string, CoinEntity[]> = new Map();
private readonly cellSize: number;
constructor(cellSize: number) {
this.cellSize = cellSize;
}
insert(coin: CoinEntity): void {
const key = this.cellKey(coin.position);
if (!this.cells.has(key)) this.cells.set(key, []);
this.cells.get(key)!.push(coin);
}
query(position: Vector2D, radius: number): CoinEntity[] {
const result: CoinEntity[] = [];
const minCx = Math.floor((position.x - radius) / this.cellSize);
const maxCx = Math.floor((position.x + radius) / this.cellSize);
const minCy = Math.floor((position.y - radius) / this.cellSize);
const maxCy = Math.floor((position.y + radius) / this.cellSize);
for (let cx = minCx; cx <= maxCx; cx++) {
for (let cy = minCy; cy <= maxCy; cy++) {
const coins = this.cells.get(`${cx},${cy}`) ?? [];
result.push(...coins);
}
}
return result;
}
clear(): void { this.cells.clear(); }
private cellKey(pos: Vector2D): string {
return `${Math.floor(pos.x / this.cellSize)},${Math.floor(pos.y / this.cellSize)}`;
}
}
Time lost: ~2 hours to profile, ~1 hour to implement.
Bug 5: Background Rendering Eating Frame Budget
Symptom: CPU profiling showed the background draw pass (ocean, clouds, horizon) consuming ~40% of the frame budget even though it changes slowly.
Fix: Layered canvas strategy. Two <canvas> elements, absolutely positioned and stacked. The background canvas renders at 10fps via setInterval. The foreground canvas (player, obstacles, coins, particles) continues at 60fps via requestAnimationFrame. This halved the render budget on mid-range devices.
// components/Game/GameCanvas.tsx
const BG_RENDER_INTERVAL_MS = 100; // 10fps
useEffect(() => {
const bgInterval = setInterval(() => {
renderBackground(bgCanvasRef.current, stateRef.current);
}, BG_RENDER_INTERVAL_MS);
return () => clearInterval(bgInterval);
}, []);
Time lost: ~1 hour to profile and implement.
Bug 6: Game Over Screen Feeling Abrupt
Symptom: The transition from gameplay to game over was an instant visual jump — jarring enough that early playtesters mentioned it unprompted.
Fix: A post-run animation sequence: screen fades to a dark overlay over 300ms, then the game-over panel slides in from the bottom, then the score counts up digit by digit before showing the full stats. Implemented with CSS keyframe animations and a small state machine:
type GameOverPhase = 'fading' | 'sliding' | 'counting' | 'complete';
const [phase, setPhase] = useState<GameOverPhase>('fading');
useEffect(() => {
if (phase === 'fading') setTimeout(() => setPhase('sliding'), 300);
if (phase === 'sliding') setTimeout(() => setPhase('counting'), 400);
if (phase === 'counting') {
// ... animated score counter
setTimeout(() => setPhase('complete'), 800);
}
}, [phase]);
Time lost: ~3 hours (mostly design iteration).
Performance Engineering
Performance isn't an optimization you do at the end — it's a series of decisions you make throughout. The ones that mattered most in Surf Rush:
Delta-time physics. All movement calculations multiply by deltaTime (seconds since last frame). A coin moves speed * deltaTime pixels per frame, not speed pixels. This means game physics are identical at 30fps and 60fps, and don't explode when the tab regains focus.
Object pooling for particles. Particle effects (coin collection sparks, collision debris) create and destroy objects frequently. On the garbage-collected JS runtime, this triggers GC pauses at the worst possible moments. Object pooling pre-allocates a fixed array of particle objects and reuses them, preventing allocations inside the hot loop.
devicePixelRatio scaling, done once. The canvas context scale operation is called once at initialization, not on every frame. All draw coordinates use CSS pixels; the scale handles the DPR conversion transparently.
Layered canvas for background. Described above — background at 10fps, foreground at 60fps.
Typed arrays for bulk entity data. For the particle system (which can have 200+ simultaneous particles), positions are stored in Float32Array rather than object arrays, improving cache locality and reducing memory allocation overhead.
Responsive Design
Surf Rush is designed for a mobile-first world. The Telegram Mini App viewport is typically 390px wide on a modern iPhone, narrower on older Androids, and arbitrarily wide on desktop.
Canvas Aspect Ratio Lock
The game world is defined in a 9:16 aspect ratio (portrait mobile). The canvas maintains this ratio regardless of the container, with letterboxing applied when the viewport is wider:
const constrainCanvas = (containerWidth: number, containerHeight: number) => {
const gameAspect = 9 / 16;
const containerAspect = containerWidth / containerHeight;
if (containerAspect > gameAspect) {
// Wider than game — letterbox horizontally
return {
width: containerHeight * gameAspect,
height: containerHeight,
};
} else {
// Taller than game — letterbox vertically
return {
width: containerWidth,
height: containerWidth / gameAspect,
};
}
};
Fluid UI
All UI outside the canvas uses CSS custom properties and clamp() for fluid sizing:
:root {
--font-size-sm: clamp(11px, 2.5vw, 14px);
--font-size-md: clamp(14px, 3.5vw, 18px);
--font-size-lg: clamp(18px, 4.5vw, 24px);
--spacing-sm: clamp(6px, 1.5vw, 10px);
--spacing-md: clamp(10px, 2.5vw, 16px);
}
Touch Controls
Desktop uses keyboard arrow keys (or A/D) and spacebar to pause. Mobile uses swipe gesture detection and two on-screen touch buttons. The swipe detection uses touchstart/touchend events with a minimum distance threshold to prevent accidental inputs from micro-movements.
Lessons Learned
Writing these down honestly, in the order they were learned:
Separate engine from framework from day one. The most impactful structural decision — made before writing any game code — was putting all game logic in src/engine/ with zero React imports. Every time I needed to refactor physics or rendering, the surface area was contained. React never knew anything about the game world.
Test on real devices in week one. Every mobile bug I hit would have been caught in the first session on physical hardware. Chrome DevTools' device simulator lies about viewport behavior, safe area insets, and touch event timing. Buy a cheap Android if you don't have one.
Build the readiness guard first in any Telegram integration. The Telegram SDK initialization race condition exists on every device, just more visibly on slower ones. The isReady guard in useTelegram should be the first line written when integrating the SDK.
Delta time is not optional. Game physics without delta time is playable at 60fps and broken at anything else. Add it from the start.
Ship the polished core, not the complete feature set. Surf Rush launched without cloud leaderboards, achievements, or multiplayer. It launched with a smooth gameplay loop, a working progression system, and no broken UI. That was the right call.
Profile before optimizing. The background rendering issue and the Magnet performance issue were both invisible until I opened the browser's Performance tab and looked at the flame graph. Intuition about performance is often wrong. Measure first.
Roadmap
The architecture was built with these extensions in mind:
Cloud leaderboard. Swap the localStorage implementation in playerStore.ts for a Supabase backend. Two functions to rewrite; zero UI changes.
TON blockchain integration. Telegram's native TON blockchain enables converting in-game coins to on-chain tokens at session end. The SurfRushRewards.sol contract is the prototype for this integration.
NFT cosmetics. Board skins and character variants minted as ERC-721 tokens. Players own their cosmetics across games on any platform that supports the standard.
Multiplayer ghost races. Real-time ghost racing where players see recorded "ghost" runs as transparent overlays on the same course. No server-side game logic required — ghosts are just replayed input sequences.
Achievements system. Persistent badges for milestone events: first level-up, 1000 coins collected, 10-day streak, collision survived by shield. Achievements are orthogonal to missions — they're permanent records, not daily resets.
Seasonal events. Time-limited course themes (winter ice, Halloween night) with exclusive cosmetic drops. The procedural generator already accepts a theme parameter; themes just need art and configuration.
Analytics. Session length, mission completion rates, and store purchase conversion by level — the data to make informed decisions about the progression curve and store economy.
System Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐
│ Telegram Client │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ WebView / Mini App │ │
│ │ ┌──────────────┐ ┌────────────────────────────────┐ │ │
│ │ │ React UI │ │ HTML5 Canvas Game Engine │ │ │
│ │ │ ─ Menus │◄───│ ─ gameLoop.ts (rAF 60fps) │ │ │
│ │ │ ─ HUD │ │ ─ physics.ts (delta time) │ │ │
│ │ │ ─ Store │ │ ─ renderer.ts (2D context) │ │ │
│ │ │ ─ Missions │ │ ─ collisions.ts (AABB) │ │ │
│ │ │ ─ Profiles │ │ ─ procedural.ts (generation) │ │ │
│ │ └──────┬───────┘ └──────────────┬─────────────────┘ │ │
│ │ │ │ │ │
│ │ ┌──────▼───────────────────────────▼─────────────────┐ │ │
│ │ │ Custom Hooks Layer │ │ │
│ │ │ useGameState │ usePlayerData │ useTelegram │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────────────▼─────────────────────────────┐ │ │
│ │ │ Persistence & Platform │ │ │
│ │ │ localStorage │ Telegram SDK │ Ethers.js │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│
┌───────────────▼────────────────┐
│ EVM Smart Contract │
│ SurfRushRewards.sol │
│ ─ saveScore() │
│ ─ claimReward() │
│ ─ highScores mapping │
└────────────────────────────────┘
Closing Thoughts
Surf Rush started as a question — can you build a genuinely engaging, feature-complete game inside Telegram using only web technologies? — and the answer is yes, but with caveats that are worth naming clearly.
The web game stack (React + TypeScript + HTML5 Canvas) is more capable than most game developers give it credit for. A well-architected 60fps game loop runs smoothly on mid-range mobile hardware if you respect the rendering budget, use delta time, and keep the engine out of the React render cycle.
The Telegram Mini App platform is genuinely exciting for distribution, but the SDK documentation is thin and the initialization behavior is inconsistent across clients. Build defensive wrappers around every API call from day one.
TypeScript on a project like this isn't overhead — it's the guardrail that makes aggressive refactoring possible without fear. The number of runtime bugs that were caught at compile time exceeded the number that made it to the browser.
And the most important engineering lesson of the project: ship the polished core. The game that went live was missing cloud leaderboards, NFT integration, and multiplayer. But it was smooth, fast, and fun — and those three properties are harder to add after the fact than any feature.
The code is open source. Take it, break it, improve it, or use it as a reference for your own Telegram Mini App. That's what open source is for.
Live Demo & Repository
🎮 Play Now: surf-rush-game.vercel.app
💻 GitHub: github.com/dheerajraj2103-wq/surf-rush-game
🤝 Connect: linkedin.com/in/m-p-dheeraj-raj-86810524b
#etherauthority #SecureChainAI #Web3 #TelegramMiniApp #React #TypeScript #HTML5Canvas #GameDevelopment #FrontendDevelopment #JavaScript #Vercel #SoftwareEngineering #OpenSource
Top comments (0)