

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:
1npm install react@latest react-native-webThis 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):
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:
1npx expo start --webExpo 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.
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.
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:
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.
1type MenuButtonProps = {
2 label: string;
3 href: LinkProps["href"];
4 width: number;
5 height: number;
6 h: number;
7};Now everything scales:
1borderRadius: 15 * h,
2padding: 3 * h,
3shadowRadius: 8 * h,
4shadowOffset: { width: 0, height: 6 * h },
5borderWidth: 2 * h,Text becomes:
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:
1gap: 16 * h,Title block:
1borderRadius: 15 * h,
2padding: 3 * h,
3borderWidth: 2 * h,Title text:
1fontSize: 24 * h,
2letterSpacing: 1 * h,Start button:
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:
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:
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:
1top: 50 * h
2size={40 * h}Pause / Win / Lose Titles:
1fontSize: 50 * hOverlay Buttons:
1size={40 * h}Home Button:
1size={40 * h}Start Overlay Play Button
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.
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.
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.
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.