

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
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:
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:
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:
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:
1<Pressable onPress={moveCharacter} style={{ position: "absolute", inset: 0 }} />Each tap increases the character speed, clamps it, and syncs the Lottie playback speed:
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:
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.
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:
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.
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:
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.
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.
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:
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.

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