Back
Amine BEN YEDDER
Amine BEN YEDDER
Build a Bicycle tap Game in React Native Part 4: Game Movement

Build a Bicycle tap Game in React Native Part 4: Game Movement

Previous: Build a Bicycle tap Game in React Native Part 3: Assets

This section explains how game movement and camera behavior are implemented by separating world logic from screen rendering to ensure smooth, predictable motion. It covers updating entity positions in world space using frame-based updates, converting those positions into on-screen transforms, and applying player input as speed rather than direct movement. It also introduces a camera system that moves along with the character, scrolling the world while keeping the character visually centered. The focus is on building a clean, self-contained movement and camera loop that operates independently of rendering.

World coordinates

Movement is driven by world coordinates, which represent the logical position of each entity inside the game world. These coordinates are independent from rendering and are stored as Reanimated shared values so they can be updated every frame without triggering React re-renders.

Define world positions as shared values

App.tsx
1const monsterPosition = useSharedValue(MONSTER_INITIAL_POSITION); 2 3const characterPosition = useSharedValue(CHARACTER_INITIAL_POSITION); 4const characterSpeed = useSharedValue(0);
  • monsterPosition and characterPosition represent X positions in world space.
  • characterSpeed controls how fast the character advances through the world.

These values describe where things are, not how they are drawn. Rendering comes later by mapping these world coordinates to screen transforms.

Update world coordinates every frame

World positions are updated inside a frame callback that runs at the display refresh rate:

App.tsx
1useFrameCallback(() => { 2 monsterPosition.value++; 3 characterPosition.value += characterSpeed.value; 4});

This creates a basic world simulation:

  • The monster advances steadily (+1 per frame)
  • The character advances based on its current speed

You’ll refine this later (delta time, clamping, collisions). For now, this is the minimal “movement loop.”

Convert world coordinates into on-screen positions

To display an entity at its world X coordinate, we map it to the screen using a transform:

App.tsx
1const monsterStyle = useAnimatedStyle(() => ({ 2 transform: [{ translateX: monsterPosition.value }], 3})); 4 5const characterStyle = useAnimatedStyle(() => ({ 6 transform: [{ translateX: characterPosition.value }], 7}));

Then we apply those styles to Animated.View wrappers:

App.tsx
1<Animated.View style={[{ position: "absolute", bottom: GROUND_LEVEL }, monsterStyle]}> 2 <DotLottie 3 source={require("./assets/monster.lottie")} 4 style={{ 5 width: MONSTER_SIZE, 6 height: MONSTER_SIZE, 7 }} 8 loop 9 autoplay 10 /> 11</Animated.View> 12... 13<Animated.View style={[{ position: "absolute", bottom: GROUND_LEVEL }, characterStyle]}> 14 <DotLottie 15 source={require("./assets/character.lottie")} 16 style={{ 17 width: CHARACTER_SIZE, 18 height: CHARACTER_SIZE, 19 }} 20 loop 21 autoplay 22 /> 23</Animated.View>

At this stage:

  • World X = screen X
  • We’re not scrolling the world yet (no camera offset)
  • Each entity simply moves across the screen as its world coordinate increases

Player input: increase speed on tap

We use a full-screen Pressable to capture taps:

App.tsx
1<Pressable onPress={moveCharacter} style={{ position: "absolute", inset: 0 }} />

Each tap increases the character speed, clamps it, and syncs the Lottie playback speed:

App.tsx
1const moveCharacter = () => { 2 characterSpeed.value = Math.min(characterSpeed.value + 0.5, 2); 3 characterRef.current?.setSpeed(characterSpeed.value); 4 characterRef.current?.play(); 5};

So tapping doesn’t directly change position it changes speed, and the frame loop turns speed into movement.

Using onLoop as a movement “decay” mechanic

The character animation loop is used as a timing source to decay speed:

App.tsx
1const onCharacterAnimationLoop = () => { 2 const newSpeed = Math.max(characterSpeed.value - 0.5, 0); 3 characterSpeed.value = newSpeed; 4 5 if (newSpeed > 0) { 6 characterRef.current?.setSpeed(newSpeed); 7 characterRef.current?.play(); 8 } else { 9 characterRef.current?.stop(); 10 } 11};

This keeps movement feeling “bursty”:

  • tap → speed goes up
  • each animation loop → speed goes down
  • eventually speed reaches 0 and the character stops

Key takeaway

  • the monster moves forward automatically
  • the character moves forward only when speed is applied and decelerates on animation loop
  • world space and screen space are still aligned (no camera offset yet)

With movement running in world space, we’ve built the core of the simulation. Now we’ll add the missing piece that makes it feel like a real game: a camera system that follows the character and scrolls the level.

Camera Logic

With movement running in world space, entities advance and update correctly each frame. However, world space and screen space are still identical—once the character reaches the edge of the screen, we stop tracking them, and the level feels limited to a single view. The flag is also tied to the screen instead of the end of the world.

To solve this, we introduce camera logic by shifting the view along with the character. The character continues moving in world coordinates, while the background and world elements scroll to follow, allowing the level to extend beyond one screen.

Defining the world length

To allow the level to extend beyond a single screen, we first define the length of the game world. In this example, the world is simply built by placing multiple background segments side by side.

App.tsx
1const worldWidth = backgroundWidth * 2;

Here:

  • backgroundWidth represents the width of a single background segment
  • multiplying it by 2 creates a world that spans two background screens

This gives us a clear and predictable world boundary:

  • entities can move freely within 0 → worldWidth
  • the camera can scroll across the entire level
  • landmarks like the flag can be placed at the true end of the world

Screen width and character center position

To implement camera behavior, we need to know where the character should appear on screen while the world scrolls. For this, we read the screen width and compute a centered position for the character:

App.tsx
1const { height, width } = Dimensions.get("window"); 2const characterCenterX = width / 2 - CHARACTER_SIZE / 2;

Here:

  • width represents the visible screen width
  • CHARACTER_SIZE is the rendered width of the character
  • characterCenterX is the X position that visually centers the character on screen

This value defines the camera’s tracking point:

  • before reaching this point, the character moves normally on screen
  • once this point is reached, the camera starts moving along with the character
  • the character continues advancing in world space but appears stable on screen

By separating world position from screen position, this centered value becomes the anchor for the camera. The world scrolls, the level extends naturally, and the character remains comfortably visible throughout the run.

Adding new animated styles: Camera and Flag

With the world length and the character’s screen anchor defined, we can now add animated styles that translate world coordinates into what’s visible on screen.

At this stage, the goal is simple:

  • move the world along with the character (camera logic)
  • place the flag at a fixed position at the end of the world

World (camera) animated style

The camera is implemented by translating the entire world container in the opposite direction of the character’s movement.

App.tsx
1const worldStyle = useAnimatedStyle(() => ({ 2 transform: [ 3 { 4 translateX: -Math.max( 5 0, 6 Math.min( 7 characterPosition.value - characterCenterX, 8 worldWidth - width, 9 ), 10 ), 11 }, 12 ], 13}));

Let’s break this down step by step:

characterPosition.value - characterCenterX

This is the ideal camera shift:

  • when the character is before the center, the value is negative
  • when the character moves past the center, the value becomes positive
  • this keeps the character visually centered while the world scrolls

Then clamp this value so the camera never scrolls outside the world bounds:

Math.min(characterPosition.value - characterCenterX, worldWidth - width)

  • worldWidth - width is the maximum scroll distance
  • this prevents the camera from moving past the end of the level

Then we prevent negative scrolling at the start:

Math.max(0, Math.min(characterPosition.value - characterCenterX, worldWidth - width))

  • ensures the camera never moves left of the world start

Finally, we negate the value:

translateX: -Math.max(0, Math.min(characterPosition.value - characterCenterX, worldWidth - width))

Because:

  • the character moves forward in world space
  • the world must move backward on screen

This creates the side-scrolling effect while keeping the character visually stable.

Flag animated style (world-anchored landmark)

The flag is placed at a fixed world position, independent of the screen:

App.tsx
1const flagStyle = useAnimatedStyle(() => ({ 2 transform: [{ translateX: worldWidth - FLAG_OFFSET - FLAG_SIZE }], 3}));

This means:

  • the flag always sits near the end of the world
  • it does not move relative to the world itself
  • when the camera reaches the end, the flag naturally appears on screen

Key idea:

  • the flag’s position is defined in world coordinates
  • the camera (worldStyle) determines when it becomes visible

World Rendering

With all the movement and camera logic in place, the final step is to update how the world is rendered on screen.

Duplicate the background

Since we defined the world to be twice the width of a single background, we need to visually represent that world by rendering two background segments side by side.

App.tsx
1<Background width={backgroundWidth} height={backgroundHeight} /> 2<Background width={backgroundWidth} height={backgroundHeight} />

This keeps world rendering simple and flexible:

  • the world length is controlled by how many background segments you render
  • the camera logic works automatically as long as worldWidth matches the total rendered width
  • adding longer levels only requires duplicating the background and updating the world size

Wrap all assets inside the world

Once camera logic is enabled, everything that belongs to the game world must be rendered inside the same world container. This is what makes the camera work: instead of moving each element manually, we translate the entire world using worldStyle.

App.tsx
1<Animated.View 2 style={[{ position: "relative", flexDirection: "row" }, worldStyle]} 3> 4 <Background width={backgroundWidth} height={backgroundHeight} /> 5 <Background width={backgroundWidth} height={backgroundHeight} /> 6 7 {/* Monster */} 8 <Animated.View 9 style={[ 10 { position: "absolute", bottom: GROUND_LEVEL }, 11 monsterStyle, 12 ]} 13 > 14 <DotLottie 15 source={require("./assets/monster.lottie")} 16 style={{ width: MONSTER_SIZE, height: MONSTER_SIZE }} 17 loop 18 autoplay 19 /> 20 </Animated.View> 21 22 {/* Flag */} 23 <View 24 style={{ 25 position: "absolute", 26 bottom: GROUND_LEVEL, 27 right: FLAG_OFFSET, 28 }} 29 > 30 <Flag width={FLAG_SIZE} height={FLAG_SIZE} /> 31 </View> 32 33 {/* Character */} 34 <Animated.View 35 style={[ 36 { position: "absolute", bottom: GROUND_LEVEL }, 37 characterStyle, 38 ]} 39 > 40 <DotLottie 41 ref={characterRef} 42 source={require("./assets/character.lottie")} 43 style={{ width: CHARACTER_SIZE, height: CHARACTER_SIZE }} 44 onLoop={onCharacterAnimationLoop} 45 loop 46 autoplay={false} 47 /> 48 </Animated.View> 49</Animated.View>

Why this matters

Once camera logic is introduced, screen space and world space are no longer the same. At that point, how and where elements are rendered becomes critical.

Wrapping all world assets inside a single world container ensures that:

  • The camera moves the world, not individual elements
    By translating one container (worldStyle), every background segment, enemy, and landmark stays perfectly aligned.
  • All entities share the same coordinate system
    The monster, character, and flag are positioned using world coordinates, which makes movement, collisions, and boundaries predictable.
  • Landmarks stay anchored to the world
    Elements like the flag can live at a fixed world position (e.g. the end of the level), instead of being tied to the visible screen.
  • Extending the level stays simple
    Adding more background segments or obstacles doesn’t require changing camera logic—only the world length.
  • Visual bugs are avoided
    Without this structure, elements may appear to “slide,” drift, or stick to the screen when the camera moves.

In short, once the camera exists, everything that should scroll must belong to the world. Only screen-fixed UI (buttons, overlays, HUD) should live outside this container.

Flag (from screen-anchored to world-anchored)

Previously, the flag was positioned using right: FLAG_OFFSET, which anchored it to the screen. This caused the flag to remain visible at the edge of the screen while the world scrolled.

To fix this, the flag is now rendered as part of the world and positioned using a world-based animated style:

App.tsx
1<Animated.View 2 style={[ 3 { 4 position: "absolute", 5 bottom: GROUND_LEVEL, 6 }, 7 flagStyle, 8 ]} 9> 10 <Flag width={FLAG_SIZE} height={FLAG_SIZE} /> 11</Animated.View>

By using flagStyle, the flag’s position is defined in world coordinates, allowing it to scroll naturally with the camera and appear only when the player reaches the end of the level.

At this point, the game has a complete and coherent movement system: entities advance in world space, the camera scrolls the world to follow the character, and all assets: backgrounds, monster, and flag are anchored to the same coordinate system.

Game movement preview

By separating world logic from screen rendering, the code remains easy to reason about and extend. Adding longer levels, new obstacles, or additional landmarks now becomes a matter of defining world positions, without touching the camera or movement logic.

In the next section, we’ll shift focus to game logic: detecting collisions, defining win, lose, and pause conditions, and reacting to what happens inside the world as the player progresses.

Next: Build a Bicycle tap Game in React Native Part 5: Game Logic