Back
Amine BEN YEDDER
Amine BEN YEDDER
 Build a Bicycle tap Game in React Native Part 7: Web Port

Build a Bicycle tap Game in React Native Part 7: Web Port

Previous: Build a Bicycle tap Game in React Native Part 6: Game Menu

This section focuses on porting the game to the web while preserving the exact architecture and gameplay behavior established in the mobile version. It walks through enabling React Native Web support, configuring Expo for web builds, and validating the full navigation flow from menu to dynamic levels inside the browser. The section then dives into fixing responsiveness issues specific to desktop environments by improving SVG background rendering, reacting to window resizes, and introducing a normalized scaling system to keep UI and gameplay proportions consistent across screen sizes. Finally, it covers adding a robust web input layer with keyboard shortcuts and click-based controls, ensuring the game feels native in the browser while remaining state-driven, predictable, and fully decoupled from the core game loop.

Config

Install the web runtime dependencies

React Native Web needs a web-compatible React runtime and the react-native-web renderer. Install both from your project root:

terminal
1npm install react@latest react-native-web

This ensures your React version matches what the web renderer expects and gives Expo the pieces it needs to translate React Native primitives (View, Text, Pressable, etc.) into DOM elements.

Enable basic expo Web configuration

Expo reads Web-specific configuration from app.json. Add a web section (or merge it into your existing config):

app.json
1{ 2 "expo": { 3 "web": { 4 "favicon": "./assets/icon.png" 5 } 6 } 7}

The favicon isn’t required for the game logic to work, but it’s part of making the project feel “real” on web: your browser tab uses a proper icon, and your build output is cleaner and closer to production-ready.

Run the game in a browser

Now start Expo in Web mode:

terminal
1npx expo start --web

Expo will bundle your app for the browser and open it in a new tab. At this stage, you should be able to click through the full flow:

  • Landing menu loads instantly (no simulation running yet)
  • "Start Game" routes to level selection
  • Selecting a level routes to /levels/[id]
  • The level reads params and builds the world dynamically
  • Gameplay runs exactly like mobile, but input is mouse/touchpad clicks

Once those basics are in place, you’ve effectively unlocked a web build of your game with the same architecture: the menu remains lightweight, routing stays declarative, levels remain configuration-driven, and the gameplay screen still reads its parameters from the route. From here, you can treat the browser as another platform target useful for quick iteration, sharing demos, or publishing a playable version without asking anyone to install the app.

Fixing Responsiveness

Once the game is running on web, layout issues tend to surface quickly. Desktop screens introduce much wider aspect ratios, dynamic resizing, and different rendering constraints compared to mobile devices. Elements that were perfectly sized on a phone can appear clipped, stretched, or misaligned in the browser. To address this, we need to make a few targeted adjustments so the visual structure adapts gracefully to changing window sizes while preserving the original design intent.

Background

On web, SVG rendering can get clipped when it’s scaled or positioned absolutely, especially if the parent layout is tight. To make the background scale more reliably across screen sizes, update your Background.tsx to forward style explicitly and force the SVG overflow to stay visible.

assets/Background.tsx
1export default function Background({ style, ...props }: SvgProps) { 2 return ( 3 <Svg 4 viewBox="0 0 6650 2410" 5 style={[{ overflow: "visible" }, style]} 6 {...props} 7 > 8 // Rest stays the same 9 </Svg> 10 ) 11}

This keeps your existing API intact while ensuring any sizing/positioning styles you pass from screens (menu, level select, level) are applied properly, and the SVG won’t get visually cropped when the browser window changes.

Dynamic Window Dimensions

On web, Dimensions.get("window") is read once and does not automatically react to browser resizes, which can cause layout issues on desktop. To make the UI truly responsive, especially for background scaling and menu layout, switch to useWindowDimensions(), which re-renders your component whenever the window size changes.

index.tsx
1const { height, width } = useWindowDimensions();

You can now use width and height exactly the same way as before, but the layout will automatically update when the browser window is resized. This small change is especially important on web, where users frequently resize their screens, and it keeps your background scaling, aspect ratio logic, and menu positioning consistent across all screen sizes.

Fully Responsive UI

To achieve true responsiveness across mobile and web, we introduce a scalable layout system based on normalized height (h) and width (w) ratios. This allows all UI elements, spacing, typography, and gameplay dimensions to scale consistently across screen sizes and aspect ratios.

Scaling Strategy

Instead of relying on fixed pixel values, we define two scale factors:

index.tsx
1const h = height / 384; 2const w = width / 853.3333333333334;

These reference dimensions correspond to the original design size. Every size, margin, offset, and font size is now multiplied by either h or w.

This gives us:

  • Vertical consistency → use h
  • Horizontal consistency → use w
  • Stable proportions on phones, tablets, and web screens

Making Menu Buttons Responsive

We modify MenuButton to accept h as a prop, so all visual metrics inside the component scale correctly.

components/menu-button.tsx
1type MenuButtonProps = { 2 label: string; 3 href: LinkProps["href"]; 4 width: number; 5 height: number; 6 h: number; 7};

Now everything scales:

components/menu-button.tsx
1borderRadius: 15 * h, 2padding: 3 * h, 3shadowRadius: 8 * h, 4shadowOffset: { width: 0, height: 6 * h }, 5borderWidth: 2 * h,

Text becomes:

components/menu-button.tsx
1fontSize: 20 * h, 2letterSpacing: 1 * h,

This ensures perfect visual scaling on all screen sizes.

Responsive Home Screen Layout

In app/index.tsx, all spacing, padding, and text sizes are now scaled using h.

Example:

app/index.tsx
1gap: 16 * h,

Title block:

app/index.tsx
1borderRadius: 15 * h, 2padding: 3 * h, 3borderWidth: 2 * h,

Title text:

app/index.tsx
1fontSize: 24 * h, 2letterSpacing: 1 * h,

Start button:

app/index.tsx
1<MenuButton 2 href="/levels" 3 label="START GAME" 4 width={175 * h} 5 height={50 * h} 6 h={h} 7/>

This makes the entire menu scale fluidly from small mobile screens to large desktop monitors.

Responsive Level Select Screen

Same logic applies here, Each level button scales dynamically:

app/levels/index.tsx
1<MenuButton 2 width={50 * h} 3 height={50 * h} 4 h={h} 5/>

This ensures:

  • Equal spacing
  • Uniform layout
  • Balanced UI proportions across devices

Scaling Gameplay Constants

Now we fully detach gameplay from hardcoded pixels. Instead of using defined consts, We compute dynamic values based on screen scale:

app/levels/[id]/index.tsx
1const groundLevel = GROUND_LEVEL * h; 2const monsterSize = MONSTER_SIZE * h; 3const monsterInitialPosition = MONSTER_INITIAL_POSITION * w; 4const flagSize = FLAG_SIZE * h; 5const flagOffset = FLAG_OFFSET * w; 6const characterSize = CHARACTER_SIZE * h; 7const characterInitialPosition = CHARACTER_INITIAL_POSITION * w;

Why this matters:

  • Keeps physics, spacing, and timing identical
  • Prevents stretched gameplay on wide screens
  • Maintains consistent game feel across platforms

Scaling All In-Game UI Controls

Pause Button:

app/levels/[id]/index.tsx
1top: 50 * h 2size={40 * h}

Pause / Win / Lose Titles:

app/levels/[id]/index.tsx
1fontSize: 50 * h

Overlay Buttons:

app/levels/[id]/index.tsx
1size={40 * h}

Home Button:

app/levels/[id]/index.tsx
1size={40 * h}

Start Overlay Play Button

app/levels/[id]/index.tsx
1size={160 * h}

This ensures:

  • Touch targets stay large enough on phones
  • Desktop UI doesn’t feel oversized
  • Visual hierarchy remains consistent

At this point, your game is fully responsive, web-ready, and resolution-independent, which is exactly what you want for production-grade React Native games.

Keyboard Controls

When porting the game to the web, input handling becomes slightly more complex than on mobile. In addition to touch or mouse clicks, players expect responsive keyboard controls, and the browser itself introduces default behaviors (scrolling, focus, navigation) that can easily interfere with gameplay. To keep the experience consistent and game-like, we need a clear, intentional input layer that works across states and avoids unexpected browser side effects.

On web, we want three things at once:

  • Keyboard shortcuts that depend on the current gameState
  • Stable callbacks so the listener never captures stale closures
  • A full-screen click/tap input layer that accelerates the character, but doesn’t react to Enter or Space like a normal HTML button

Wrap actions in useCallback

All actions triggered by keyboard input should have stable identities. In this game, callbacks that only touch shared values and refs can safely use []. The only one that must depend on values is restartGame, because initial positions can change with level params / responsive scaling.

app/levels/[id]/index.tsx
1const moveCharacter = useCallback(() => { 2 characterSpeed.value = Math.min(characterSpeed.value + 0.5, 2); 3 characterRef.current?.setSpeed(characterSpeed.value); 4 characterRef.current?.play(); 5}, []); 6 7const resumeGame = useCallback(() => { 8 gameState.value = "RUNNING"; 9 monsterRef.current?.play(); 10 if (characterSpeed.value > 0) characterRef.current?.play(); 11}, []); 12 13const restartGame = useCallback(() => { 14 characterPosition.value = characterInitialPosition; 15 characterSpeed.value = 0; 16 monsterPosition.value = monsterInitialPosition; 17 gameState.value = "READY"; 18 monsterRef.current?.stop(); 19 characterRef.current?.stop(); 20}, [characterInitialPosition, monsterInitialPosition]); 21 22const pauseGame = useCallback(() => { 23 gameState.value = "PAUSED"; 24 pauseLotties(); 25}, [pauseLotties]); 26 27const startGame = useCallback(() => { 28 gameState.value = "RUNNING"; 29 monsterRef.current?.play(); 30}, []); 31 32const navigateHome = useCallback(() => { 33 router.navigate("/"); 34}, [router]);

Add a state-driven keymap

We define shortcuts declaratively. Overlay states (PAUSED/WON/OVER) share the same commands (restart / quit), so we reuse a single overlayKeymap object.

app/levels/[id]/index.tsx
1useEffect(() => { 2 if (Platform.OS !== "web") return; 3 4 const overlayKeymap = { 5 R: restartGame, 6 r: restartGame, 7 Q: navigateHome, 8 q: navigateHome, 9 }; 10 11 const keymap: Record<GameStateType, Record<string, () => void>> = { 12 READY: { 13 Enter: startGame, 14 }, 15 RUNNING: { 16 " ": moveCharacter, 17 Escape: pauseGame, 18 Backspace: pauseGame, 19 }, 20 PAUSED: { 21 Enter: resumeGame, 22 ...overlayKeymap, 23 }, 24 WON: overlayKeymap, 25 OVER: overlayKeymap, 26 }; 27 28 const handleKeyDown = (event: KeyboardEvent) => { 29 if (event.repeat) return; 30 31 // Prevent browser defaults (Space scroll, Backspace navigation) 32 if (event.key === " " || event.key === "Backspace") { 33 event.preventDefault(); 34 } 35 36 keymap[gameState.value]?.[event.key]?.(); 37 }; 38 39 document.addEventListener("keydown", handleKeyDown); 40 return () => document.removeEventListener("keydown", handleKeyDown); 41}, [startGame, moveCharacter, pauseGame, resumeGame, restartGame, navigateHome]);

Shortcut summary:

  • Enter → Start (READY) / Resume (PAUSED)
  • Space → Accelerate (RUNNING)
  • Escape or Backspace → Pause (RUNNING)
  • R → Restart (PAUSED / WON / OVER)
  • Q → Quit to Home (PAUSED / WON / OVER)

Update the move character button

On mobile, we use Pressable. On web, we use an invisible button that captures clicks across the entire screen. However, HTML buttons are keyboard-focusable by default, so we explicitly prevent it from reacting to keyboard events.

app/levels/[id]/index.tsx
1{Platform.OS === "web" ? ( 2 <button 3 onClick={moveCharacter} 4 onKeyDown={(e) => e.preventDefault()} 5 onKeyUp={(e) => e.preventDefault()} 6 style={{ 7 position: "absolute", 8 inset: 0, 9 background: "transparent", 10 }} 11 /> 12 ) : ( 13 <Pressable 14 onPress={moveCharacter} 15 style={{ 16 position: "absolute", 17 inset: 0, 18 }} 19 />) 20}

This gives you:

  • Click/tap anywhere to accelerate
  • Keyboard controls handled globally via the keymap
  • No accidental triggering via Enter/Space on the overlay button layer

That’s the full keyboard + web input setup: predictable, state-driven, and cleanly separated from the game loop.