---
title: 'Building NeonCity: A fast-paced mobile game made with React Native, Reanimated, and Expo'
authors: Daehyeon Mun
published: June 10, 2025
categories: Development, Users
tags: React Native, game development, mobile app game development, sprite sheets, Reanimated, open source, NativeWind
---

Do you like playing games?

My friend, designer [Woojae Lee](https://www.woojaelee.net) and I absolutely love games. In the summer of 2023, after watching **Cyberpunk: Edgerunners** on Netflix, we were inspired to create a visually stunning game set in a futuristic city.

Having previously developed dozens of games using React Native, we were confident that we could bring our ambitious vision to life. With excitement, we embarked on the NeonCity project.

## What is NeonCity?

NeonCity is a casual game set in the futuristic city of 2077. Players must perform swipe gestures corresponding to arrows falling from the top of the screen, aiming to achieve the highest score possible within a 1-minute time limit.

Successfully executing consecutive accurate gestures grants combo bonus points, and using score multiplier items allows players to further boost their scores. The fast-paced, urban soundtrack and dazzling visual effects greatly enhance the gaming experience.

Here is a link to the game in the [App Store](https://apps.apple.com/us/app/neoncity-cityrunner/id6477771151?platform=iphone) and the [Google Play](https://play.google.com/store/apps/details?id=com.justforfun.neoncity). 

## The architecture of NeonCity

You can build beautiful, interesting games with React Native and Expo. The following sections hopefully demonstrate the key architectural decisions that will get you there. 

### The UI

![NeonCity UI](https://cdn.sanity.io/images/9r24npb8/production/19b62bfc388c2dc94e9d87320739ad34091cabf9-3024x1844.png)

To support mobile platforms seamlessly, we used [NativeWind](https://www.nativewind.dev/) to build the UI. NativeWind enabled us to deliver a consistent user experience across platforms while simplifying maintenance.

### Gesture detection

![Gesture detection](https://cdn.sanity.io/images/9r24npb8/production/49f7046a73df3a7672e972e73c70decc2c0810ad-1360x780.png)

The game requires detecting swipe gestures in four directions: up, down, left, and right.

Initially, I considered using [Fling Gesture](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/fling-gesture), but creating separate gestures for each direction requires more resources. For optimal performance, I created a custom hook using a single [Pan Gesture](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture) from `react-native-gesture-handler`. This approach measures gesture start coordinates (`onStart`) and end coordinates (`onEnd`) and calculates the gesture angle using trigonometric functions (`degBetween`) to identify all directions efficiently.

```typescript
import { useMemo } from 'react';
import { Directions, Gesture, PanGesture } from 'react-native-gesture-handler';
import { useSharedValue } from 'react-native-reanimated';
import { between, vec, Vector } from 'react-native-worklet-functions';
import { degBetween } from 'utils/math.util';

export const useFlingGesture = ({
  onEnd,
}: {
  onEnd?: (direction: Directions) => void;
}) => {
  const startPos = useSharedValue<Vector>(vec(0, 0));
  return useMemo<PanGesture>(() => {
    return Gesture.Pan()
      .onStart((e) => {
        startPos.value = vec(e.x, e.y);
      })
      .onEnd((e) => {
        let result: Directions = Directions.RIGHT;

        let angle = degBetween(startPos.value, e);
        if (angle < 0) {
          angle += 360;
        }

        if (between(angle, 45.000001, 135)) {
          result = Directions.DOWN;
        } else if (between(angle, 135.000001, 225)) {
          result = Directions.LEFT;
        } else if (between(angle, 225.000001, 315)) {
          result = Directions.UP;
        }

        onEnd?.(result);
      });
  }, [onEnd]);
};
```

### Sprite image technique

In the game, arrows move down continuously descending from the top which requires to add a new arrow on the top per gesture. It might pose performance issues if you build the UI update flow with useState due to frequent re-renders. For optimal performance, I adopted a sprite image technique which involves placing all sprites in a single image and then only displaying the part of the image containing the desired sprite.

#### What is sprite image technique?

![sprite image technique](https://cdn.sanity.io/images/9r24npb8/production/8ff1933c07a4fb1d417f69b5d3f077815ae79a08-900x900.png)

[Sprite image technique](https://www.tutorialrepublic.com/css-tutorial/css-sprites.php), also known as **sprite sheet animation**, is a graphical optimization method widely used in game development. Instead of loading multiple individual images separately, all related images (or "sprites") are combined into a single larger image, known as a "sprite sheet."

To display a specific image, only the relevant portion of the sprite sheet is shown by adjusting the viewport. This significantly reduces the number of network requests, decreases memory usage, and improves rendering performance. It allows for smoother animations and rapid visual updates, essential for maintaining consistent high frame rates, especially in fast-paced games like NeonCity.

### How to do sprite image technique in React Native styles

![sprite image technique in react native](https://cdn.sanity.io/images/9r24npb8/production/fdc68008672634e83fe6d2e0682071565c1a3814-2974x1464.png)

To implement the sprite image technique in React Native, I first created a single sprite sheet that contains multiple arrow graphics organized in rows and columns. As you can see from the provided sprite image, each arrow is placed in its own grid cell, clearly marked with row and column indices (e.g., `col:0, row:0`).

#### Implementation with useSpriteImage Hook

Using React Native’s styling capabilities with `react-native-reanimated`, I dynamically cropped the sprite sheet by adjusting the `translateX` and `translateY` transform properties to position the sprite sheet so that only the desired sprite is visible within the view bounds.

Here is how I implemented the sprite image technique efficiently using React Native and `react-native-reanimated`:

```tsx
import { useCallback, useMemo } from 'react';
import { useSharedValues } from './useSharedValues';
import { AnimatedBox, AnimatedImage } from '@components';
import { SharedValue, useAnimatedStyle } from 'react-native-reanimated';
import { ImageProps } from 'expo-image';
import { ViewProps } from 'react-native';

type Props = {
  source: ImageProps['source'];
  cellWidth: SharedValue<number>;
  cellHeight: SharedValue<number>;
  initialColIndex?: number;
  initialRowIndex?: number;
  maxColumnIndex?: number;
  maxRowIndex?: number;
};

export const useSpriteImage = ({
  source,
  cellWidth,
  cellHeight,
  initialColIndex = 0,
  initialRowIndex = 0,
  maxColumnIndex = 0,
  maxRowIndex = 0,
}: Props) => {
  // colIndex and rowIndex track the currently visible sprite's coordinates
  const { colIndex, rowIndex } = useSharedValues({
    colIndex: initialColIndex,
    rowIndex: initialRowIndex,
  });

  // Set container dimensions for viewport using reanimated
  const containerStyle = useAnimatedStyle(() => {
    return {
      width: cellWidth.value,
      height: cellHeight.value,
    };
  }, []);

  // Position the sprite image so that only the desired sprite is visible
  const imageStyle = useAnimatedStyle(() => {
    return {
      width: cellWidth.value * (maxColumnIndex + 1), // full width of sprite sheet
      height: cellHeight.value * (maxRowIndex + 1),  // full height of sprite sheet
      transform: [
        {
          translateX: -colIndex.value * cellWidth.value, // move horizontally to select column
        },
        {
          translateY: -rowIndex.value * cellHeight.value, // move vertically to select row
        },
      ],
    };
  }, []);

  // Imperatively updates the sprite shown without triggering React re-renders
  const update = useCallback((col: number, row: number) => {
    'worklet'; // ensures this runs in Reanimated's UI thread
    colIndex.value = col;
    rowIndex.value = row;
  }, []);

  // Memoize the component to avoid unnecessary re-renders
  const Component = useMemo(() => {
    return ({ style, ...rest }: ViewProps) => (
      <AnimatedBox
        style={[
          style,
          containerStyle,
          { overflow: 'hidden' }, // hides parts of image outside viewport
        ]}
        {...rest}>
        <AnimatedImage
          source={source}
          style={[
            { position: 'absolute', width: '100%', height: '100%' },
            imageStyle,
          ]}
        />
      </AnimatedBox>
    );
  }, [source, containerStyle, imageStyle]);

  return { Component, update };
};
```

#### How to avoid unnecessary re-renders?

In a traditional React Native implementation, updating UI elements by changing props or state triggers a component re-render, potentially causing performance issues—especially when updating rapidly, like in a game.

By using `react-native-reanimated`'s **shared values**, we bypass React's rendering cycle. These shared values (`colIndex` and `rowIndex`) are updated imperatively through the `update()` function directly within Reanimated’s UI thread, labeled with `'worklet'`. This method efficiently updates UI animations at 60 FPS without triggering React’s reconciliation process.

The `useAnimatedStyle` hooks then respond automatically to these shared value changes, smoothly adjusting the transform styles (translateX, translateY) without causing a React render. Thus, we achieve seamless sprite animations with optimal performance.

For developers interested in learning how Reanimated works in the codebase, I highly recommend tutorials by:

- [**William Candillon**](https://start-react-native.dev/)
- [**Dan's React Native Lab**](https://www.youtube.com/@DanRNLab)
- [**Reactiive**](https://reanimate.dev/)

## Game app deployment

I chose Expo for this project primarily because Expo SDK handles many things for React Native developers, such as native code generation with Expo CNG which saves my days and makes it easy to maintain SDK versions. Additionally, EAS Build makes deployment straightforward for both Android and iOS.

- Expo recently introduced [M4 Pro-powered workers](https://expo.dev/changelog/eas-build-workflows-m4-pro#how-to-use-them), **significantly improving EAS Build speed by 1.85** times on iOS.
- EAS Build simplifies the deployment processes to the App Store and Play Store.

Expo has proven to be a powerful solution for simultaneously managing mobile app deployment and maintenance.

## Outro

To inspire more developers to create amazing apps with React Native and Expo, I made up my mind to make the NeonCity project open-source.

You can check out the source code at the link below.

[NeonCity: CityRunner Github repository](https://github.com/daehyeonmun2021/react-native-neon-city)

Lastly, a heartfelt thanks to those who supported the completion of this project:

- Design: [Woojae Lee](https://www.woojaelee.net)
- Code Review: [Jamon Holmgren](https://x.com/jamonholmgren)
- Graphic Feedback: [Yeonggeun Kim](https://www.linkedin.com/in/yeonggeun-kim-2b6a36210/overlay/about-this-profile/)

Happy Hacking !