DEV Community

Cover image for Building Surf Rush: A Full-Featured Telegram Mini App Game with React, TypeScript & HTML5 Canvas
dheerajraj2103-wq
dheerajraj2103-wq

Posted on

Building Surf Rush: A Full-Featured Telegram Mini App Game with React, TypeScript & HTML5 Canvas

Building Surf Rush: A Full-Featured Telegram Mini App Game with React, TypeScript & HTML5 Canvas

How I engineered a real-time endless runner with XP progression, power-ups, daily missions, and a reward store — entirely inside Telegram.


Introduction

The Telegram Mini App ecosystem has quietly become one of the most exciting frontiers in web development. With over 900 million monthly active users and a built-in distribution channel that requires zero app store approval, it offers a rare opportunity: ship a polished, interactive experience directly inside a chat app that people already have open.

Surf Rush is my answer to that opportunity — a feature-rich, mobile-first endless runner game built as a Telegram Mini App using React, TypeScript, and HTML5 Canvas. Players surf through dynamically generated waves, dodge obstacles, collect coins, level up, complete daily missions, unlock power-ups, and compete on a global leaderboard — all without leaving Telegram.

This article is a deep technical walkthrough of how I built it: the architecture decisions, the bugs that nearly broke me, the solutions that saved the project, and the lessons I'm taking forward into every future build.

Whether you're a React developer curious about game loops, a Telegram Mini App developer looking for real-world patterns, a Web3 enthusiast interested in on-chain gaming foundations, or a recruiter evaluating full-stack engineering depth — this is the story of Surf Rush, told honestly.


Why I Built Surf Rush

I wanted to build something that sat at the intersection of three things I care about: real-time interactivity, progressive user engagement systems, and social-native distribution.

Most portfolio projects live on a static URL that nobody visits. Telegram Mini Apps flip that equation — the distribution channel is the product. A game that lives inside Telegram gets shared inside Telegram, which means organic virality is a feature of the platform, not an afterthought.

From a technical standpoint, I wanted to answer questions I'd been asking myself:

  • Can a React app manage a 60fps game loop without stuttering?
  • How do you design XP, leveling, and daily missions that actually feel rewarding?
  • What does mobile-first game UX look like when you're constrained to a 390px viewport inside a chat app?
  • How do Telegram's Web App APIs behave in production, and where do they break?

Surf Rush is my empirical answer to all of those questions.


Project Overview

Surf Rush is an endless runner where the player controls a surfer navigating an ocean course. The core gameplay loop is simple: survive as long as possible, collect coins, and avoid obstacles. But layered on top of that loop is a full progression system designed to keep players coming back.

Core gameplay objectives:

  • Survive increasingly fast waves and obstacle patterns
  • Collect coins scattered across the course
  • Activate power-ups (Shield, Magnet) to extend runs
  • Accumulate XP to level up and unlock cosmetic rewards
  • Complete daily missions for bonus coin rewards
  • Climb the leaderboard to earn social bragging rights

The game is fully playable in a browser at surf-rush-game.vercel.app and is designed to launch natively inside Telegram as a Mini App.


Features

Endless Runner Gameplay

The core game loop runs on an HTML5 Canvas element. The world scrolls horizontally at an accelerating speed — the longer a player survives, the faster the course becomes. Obstacles are procedurally placed using a weighted randomization system that ensures fair but escalating difficulty. A collision detection system checks bounding boxes on every frame tick.

Coin System

Coins appear along the course in randomized clusters. Each coin collected increments the player's session total and contributes to their all-time wallet balance. The coin balance persists between sessions and is used as currency in the Reward Store. Coins are rendered as canvas sprites with a simple bobbing animation to make them visually distinct from the background.

XP and Level Progression

Every run awards XP based on distance traveled and coins collected. XP accumulates across sessions, and players level up when they cross predefined thresholds. Each level-up triggers a visual celebration animation and unlocks access to new items in the Reward Store. The progression curve is designed to feel rewarding in the early game while maintaining long-term depth.

Daily Missions

Three daily missions regenerate every 24 hours. Examples include: "Collect 50 coins in a single run", "Survive for 90 seconds", or "Use a power-up 3 times." Completing missions awards bonus coins and XP. Mission state persists in localStorage with a daily timestamp check to handle resets.

// Daily mission reset logic
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()
  );
};
Enter fullscreen mode Exit fullscreen mode

Reward Store

The Reward Store is a shop interface where players spend accumulated coins on cosmetic upgrades — board skins, trail effects, and character variants. Purchased items persist in localStorage and are applied immediately to the game renderer. The store creates a long-term economy that gives coins meaning beyond the immediate run.

Leaderboard

The leaderboard ranks players by their all-time high score. In the current implementation, scores are stored locally and displayed in a ranked list UI. The architecture is designed to swap the local store for a cloud backend (Supabase or Firebase) without touching the leaderboard UI component.

Shield Power-Up

The Shield grants the player one free collision — the next obstacle hit is absorbed without ending the run. Visually, a glowing aura renders around the player sprite on the canvas. The shield state is tracked as a boolean in the game state object and consumed on the first valid collision event.

interface GameState {
  isRunning: boolean;
  score: number;
  coins: number;
  shieldActive: boolean;
  magnetActive: boolean;
  speed: number;
}
Enter fullscreen mode Exit fullscreen mode

Magnet Power-Up

The Magnet automatically draws nearby coins toward the player for a timed duration (8 seconds). This required adding a proximity check inside the coin update loop — each coin's distance to the player is calculated on every frame, and coins within the magnet radius are pulled toward the player position using linear interpolation.

// Magnet attraction logic inside game loop
if (state.magnetActive) {
  coins.forEach((coin) => {
    const dx = player.x - coin.x;
    const dy = player.y - coin.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (dist < MAGNET_RADIUS) {
      coin.x += dx * MAGNET_PULL_STRENGTH;
      coin.y += dy * MAGNET_PULL_STRENGTH;
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Telegram Sharing

After each run, players can share their score directly to a Telegram chat using the Web App's native share API. This was one of the trickiest features to implement correctly — more on that in the challenges section.

Mobile Responsiveness

The game canvas scales dynamically to the device viewport using a ResizeObserver. All UI panels (leaderboard, store, missions) are built with CSS Flexbox and clamp-based typography to remain readable at any screen size.

UI/UX Improvements

Throughout development I iterated on the game's feel: adding particle effects on coin collection, a screen-shake effect on collision, smooth easing on menu transitions, and a tutorial overlay for first-time players. These details transform a functional prototype into something that feels made.


Technology Stack

React

React was the natural choice for the menu system, HUD, and all non-canvas UI. Its component model maps cleanly to the game's distinct screens (main menu, active game, leaderboard, store), and the hook system (useState, useEffect, useRef) provides clean integration points with the imperative Canvas API.

TypeScript

TypeScript was non-negotiable on a project of this complexity. The game state object, player data model, power-up types, and mission definitions all benefit from strict typing. Catching a type mismatch at compile time rather than discovering a undefined is not a function crash mid-run is the difference between professional and amateur code.

Vite

Vite's near-instant HMR made iterating on both game logic and UI fast enough to maintain flow state. With tsc --noEmit running in watch mode alongside Vite, the feedback loop was tight: save, see the result, catch the type error, fix it — all in under two seconds.

HTML5 Canvas

The game loop runs entirely on a <canvas> element via the 2D rendering context. Using Canvas rather than a DOM-based approach gives direct control over every pixel on every frame, which is essential for smooth animation at 60fps. React manages the canvas lifecycle through a useRef, while all rendering logic lives in vanilla TypeScript functions outside the React render cycle.

CSS3

The menu system uses CSS Grid and Flexbox for layout, CSS custom properties for theming, and @keyframes animations for transitions. Keeping the game renderer in Canvas and the UI in CSS means each layer uses the right tool for its job.

Telegram Mini Apps

The @twa-dev/sdk package provides typed access to the Telegram Web App JavaScript API. I use it for: detecting the user's Telegram identity, triggering the native share sheet, controlling the back button behavior, and adapting the color scheme to match the user's Telegram theme.


Game Architecture

Folder Structure

surf-rush/
├── public/
│   └── assets/          # Sprites, audio, icons
├── src/
│   ├── components/      # React UI components
│   │   ├── Game/        # Canvas wrapper + HUD
│   │   ├── Leaderboard/
│   │   ├── Store/
│   │   ├── Missions/
│   │   └── Shared/      # Buttons, modals, transitions
│   ├── engine/          # Pure game logic (no React)
│   │   ├── gameLoop.ts
│   │   ├── physics.ts
│   │   ├── renderer.ts
│   │   ├── collisions.ts
│   │   └── entities.ts
│   ├── hooks/           # Custom React hooks
│   │   ├── useGameState.ts
│   │   ├── usePlayerData.ts
│   │   └── useTelegram.ts
│   ├── store/           # State management
│   │   └── playerStore.ts
│   ├── types/           # Shared TypeScript interfaces
│   └── utils/           # Helpers, constants, formatters
├── index.html
├── vite.config.ts
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Component Hierarchy

<App>
├── <TelegramProvider>     // Initializes Telegram Web App SDK
└── <Router>
    ├── <MainMenu>
    ├── <Game>
    │   ├── <GameCanvas>   // useRef → Canvas element
    │   ├── <HUD>          // Score, coins, power-up timers
    │   └── <GameOver>     // Post-run stats + share button
    ├── <Leaderboard>
    ├── <RewardStore>
    └── <Missions>
Enter fullscreen mode Exit fullscreen mode

Game Loop

The game loop uses requestAnimationFrame to target 60fps. The loop is started and stopped using a useEffect in the GameCanvas component, with the cancellation token stored in a useRef to prevent memory leaks.

const animationRef = useRef<number>(0);

const startLoop = () => {
  const loop = (timestamp: number) => {
    updateGameState(timestamp);    // Physics, collision, entity updates
    renderFrame(canvasRef.current); // Canvas draw calls
    animationRef.current = requestAnimationFrame(loop);
  };
  animationRef.current = requestAnimationFrame(loop);
};

// Cleanup on unmount
useEffect(() => {
  startLoop();
  return () => cancelAnimationFrame(animationRef.current);
}, []);
Enter fullscreen mode Exit fullscreen mode

The updateGameState function is a pure function that takes the current state and timestamp, applies physics, checks collisions, updates entity positions, and returns the next state. This functional approach made the game loop straightforward to test and debug.


Major Challenges

1. The Blank Screen Bug

Early in development, the game canvas would occasionally render as a blank white rectangle. The game loop was running — I could confirm via console logs — but nothing appeared on screen.

Root cause: The Canvas 2d rendering context was being retrieved before the DOM had finished mounting. The useRef returned the element reference, but the element's dimensions were 0 × 0 at the time getContext('2d') was called.

Solution: I moved context initialization into a useLayoutEffect (which runs synchronously after DOM mutations, before paint) and added an explicit dimension check:

useLayoutEffect(() => {
  const canvas = canvasRef.current;
  if (!canvas) return;
  canvas.width = canvas.offsetWidth;
  canvas.height = canvas.offsetHeight;
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  ctxRef.current = ctx;
  startLoop();
}, []);
Enter fullscreen mode Exit fullscreen mode

2. Telegram Share Bug

Clicking the share button after a run would occasionally do nothing — no dialog, no error, just silence. This only happened on certain Telegram clients and couldn't be reproduced consistently in the browser.

Root cause: The Telegram Web App SDK's showShareScreen method requires the Mini App to be fully initialized before it can be called. On slower devices, the share button was sometimes tapped before window.Telegram.WebApp.ready() had resolved.

Solution: I wrapped all Telegram API calls in a readiness guard and added an initialization promise that the rest of the app awaits:

// useTelegram.ts
const [isReady, setIsReady] = useState(false);

useEffect(() => {
  const tg = window.Telegram?.WebApp;
  if (tg) {
    tg.ready();
    tg.expand();
    setIsReady(true);
  }
}, []);

const shareScore = (score: number) => {
  if (!isReady) return;
  const tg = window.Telegram.WebApp;
  tg.openTelegramLink(
    `https://t.me/share/url?url=https://surf-rush-game.vercel.app&text=I scored ${score} in Surf Rush! 🏄‍♂️`
  );
};
Enter fullscreen mode Exit fullscreen mode

3. Mobile Responsiveness

The initial build looked perfect on desktop Chrome DevTools' mobile simulator — and broken on real devices. Text overflowed, buttons were cut off by the iOS safe area, and the canvas didn't fill the viewport correctly on Android.

Solution: A three-part fix. First, I added a ResizeObserver to the canvas element to keep its internal resolution in sync with its CSS size. Second, I added env(safe-area-inset-bottom) padding to the bottom navigation bar to avoid the iPhone home indicator. Third, I replaced all fixed pixel values in the UI with clamp() expressions and viewport units.

.game-hud {
  padding-bottom: max(12px, env(safe-area-inset-bottom));
  font-size: clamp(12px, 3vw, 16px);
}
Enter fullscreen mode Exit fullscreen mode

4. Power-Up Implementation

Implementing the Magnet required running proximity calculations on every coin for every frame — potentially hundreds of calculations at 60fps. On low-end mobile devices, this caused visible frame drops.

Solution: I added a spatial partitioning optimization using a simple grid bucketing approach: coins are sorted into a coarse grid on each frame, and proximity checks only compare the player against coins in adjacent grid cells. This reduced the number of distance calculations by roughly 80% in dense coin layouts.

5. Performance Optimization

Beyond the Magnet issue, I found that re-rendering the entire canvas background on every frame — ocean, horizon, clouds — was expensive.

Solution: I switched to a layered canvas approach using two overlapping canvas elements. The background layer (ocean, sky, clouds) renders at 10fps using a throttled setInterval. The foreground layer (player, obstacles, coins, particles) continues at 60fps. This halved the render budget on mid-range devices.

6. UI Polishing

Late in development, the menus felt functional but cold. Transitions between screens were instant jumps, button presses had no tactile feedback, and the game-over screen felt abrupt.

Solution: I added CSS transition classes using a simple state machine for screen transitions, a scale transform keyframe animation on button press (the "squish" effect), and a post-run animation sequence that counts the score up digit by digit before showing the full game-over panel.


User Experience Improvements

Tutorial Overlay: First-time players see a semi-transparent tutorial panel over the canvas that demonstrates swipe controls with animated arrow indicators. It dismisses on first interaction and never appears again, using a localStorage flag.

Onboarding Flow: The main menu detects whether the player is new (no saved data) and shows a brief welcome screen with the game's objective before dropping them into gameplay.

Animations: Coin collection triggers a floating +1 particle. Level-up triggers a full-screen flash with the new level number. Power-up activation plays a brief scale-in animation on the HUD icon.

Responsive Canvas: The canvas maintains a locked aspect ratio using a preserveAspectRatio-style calculation, preventing the game world from stretching on ultrawide or narrow screens.

Haptic Feedback: On Telegram Mobile, window.Telegram.WebApp.HapticFeedback.impactOccurred('medium') fires on collision and level-up, adding physical reinforcement to key moments.


Lessons Learned

1. Separate your game engine from your framework. Putting physics and rendering logic inside React components is a mistake. React's rendering model and a game loop's update model are fundamentally at odds. The best decision I made was early: all game logic lives in src/engine/, and React only manages the lifecycle (mount/unmount the loop) and the UI layer.

2. Test on real devices early. The mobile bugs I encountered would have been caught in week one if I'd been testing on a physical iPhone and Android device from the start. Simulators lie.

3. Telegram's SDK quirks are real. The documentation is thin and sometimes contradictory. Building a useTelegram hook that centralizes all SDK interactions — and guards every call behind a readiness check — is the right pattern.

4. Progressive enhancement beats feature completeness. The version of Surf Rush that went live was missing cloud leaderboards and achievements. But it was polished, fast, and fun. Shipping a solid core and iterating beats holding back for a perfect v1.

5. Performance is a feature on mobile. A game that runs at 45fps on a mid-range Android is a worse game than one that runs at 60fps. Budget your rendering work early and measure on real hardware.


Future Improvements

Surf Rush's architecture was built with extensibility in mind. The roadmap includes:

  • Multiplayer mode — real-time ghost racing where players see each other's recorded runs as transparent "ghosts" on the same course
  • Blockchain rewards — integrating TON (Telegram's native blockchain) to convert in-game coins to on-chain tokens at the end of each session
  • NFT cosmetics — board skins and character variants minted as NFTs that players own across games
  • Cloud leaderboard — replacing the local leaderboard with a Supabase backend for global ranking
  • Achievements system — persistent badges for milestone events (first level-up, 1000 coins collected, 10-day streak)
  • Seasonal events — time-limited course themes (winter, Halloween) with exclusive cosmetic drops
  • Analytics dashboard — session length, retention cohorts, and mission completion rates to inform future design decisions

The Web3 integration path is particularly compelling given Telegram's existing relationship with TON blockchain — it's one of the few gaming contexts where blockchain rewards feel native rather than bolted on.


Live Demo & Repository

Live Demo: https://surf-rush-game.vercel.app/

GitHub Repository: https://github.com/your-username/surf-rush

Clone the repo, run npm install && npm run dev, and the game will open at localhost:5173. To test Telegram-specific features, use the Telegram Bot API to register a Mini App pointing to your local ngrok tunnel.


Conclusion

Surf Rush started as a question — can you build a genuinely fun, feature-complete game inside Telegram using only web technologies? — and became the most technically satisfying project I've shipped.

The combination of React, TypeScript, and HTML5 Canvas proved to be a powerful and underrated game development stack. The Telegram Mini App platform provided distribution and social features that would take months to build independently. And the discipline of shipping a real product — with real bugs, real performance constraints, and real users — taught me more than any tutorial could.

If you're a developer looking to break into Telegram Mini Apps, or a React developer curious about game development, I hope this walkthrough gives you a clear and honest picture of what that work actually looks like.

The code is open source. Fork it, break it, improve it, or use it as a reference for your own Telegram Mini App game. And if you find a bug — open an issue. That's how good software gets better.


Keywords: Telegram Mini App, React Game Development, TypeScript Game, Web3 Internship Project, HTML5 Canvas Game, Endless Runner, Telegram Web App SDK, React TypeScript Vite

Top comments (0)