Integrating Unity code into React Native. Part II

Ilya Mazhugin, mobile developer
Ilya Mazhugin, mobile developer
Jun 28, 2024
14 minutes
Contents

Introduction

Hello everyone, the dev.family team is back. We continue to explore the integration of a Unity project into an application written with React Native.
Previously on dev.family chronicles…
We started by adding a Unity game to our app. What it was like? You can read on our blog. Right now we have parts of the code that don't interact with each other. It means the work's not over. And there is the «Save Result» button. It would be nice to write logic for it to show what we have.
Spoiler: that and more is what we're going to do right now.
What happens next
In the second part of our article, we'll take the current React Native + Unity bundle and make it able to receive and process messages from one part to another. And vice versa.
Let’s continue our Unity-journey!
Warning
The article is aimed to show how processes are working on a test application. It is neither a panacea nor a clear guidance of exactly how code should be written, but the solution that has worked out great for us, so we’d love to inspire you to create similar development challenges.

Preparation of application

First, let’s start with React Native code.

const UnityScreen: React.FC> = ({
 route,
}) => {
 // Start
 const unityRef = useRef(null);
 const {messageToUnity} = route.params;

 useEffect(() => {
   if (messageToUnity) {
     unityRef.current?.postMessage('', '', messageToUnity);
   }
 }, [messageToUnity]);

 const handleUnityMessage = (message: string) => {
   console.log(message);
 };
 //End

 return (
    handleUnityMessage(e.nativeEvent.message)} // and this line
   />
 );
};
Take a look on what we’ve added:
unityRef – reference to our UnityView to interact with;
messageToUnity – the message through navigation from our Home screen. This is what we’ll transmit to postMessage method in Unity;
useEffect – availability check of messageToUnity or changes in it with its further transfer to Unity;
postMessage – our message transmission to Unity on gameObject, in methodName;
handleUnityMessage – our method for processing the message from Unity.
Next we pass our unityRef in UnityView and call for handleUnityMessage in UnityView too.
To avoid unnecessary syntax highlighting, we’ll add:

type RootStackParamList = {
 [RootRoutes.HOME]: undefined;
 [RootRoutes.UNITY]: {messageToUnity?: string}; // added messageToUnity
};
After that, we’re adding the list with our results to HomeScreen, where we’ll record the 10 best tries in the game.
	// score data type
type Score = {
 date: string;
 score: number;
};

const HomeScreen: React.FC<RootStackScreenProps<RootRoutes.HOME>> = ({
 navigation,
}) => {
 const [scores, setScores] = useState<Score[]>([]); // scores to display in list

 const insets = useSafeAreaInsets();

 //List item to render
 const renderScore: ListRenderItem<Score> = useCallback(({item, index}) => {
   return (
     <View style={styles.score}>
       <Text style={styles.scoreText}>{index + 1}.</Text>
       <Text style={[styles.scoreText, styles.flex]}>{item.score}</Text>
       <Text style={styles.scoreDate}>{item.date}</Text>
     </View>
   );
 }, []);

 return (
   <View style={[styles.screen, {paddingBottom: Math.max(insets.bottom, 15)}]}>
     <Text style={styles.welcomeText}>
       Hello, from{' '}
       <Text style={[styles.welcomeText, styles.purple]}>dev.family</Text> team
     </Text>
     {/** scoreboard */}
     <Text style={styles.welcomeText}>Scores 🏆:</Text>
     <FlatList
       data={scores}
       renderItem={renderScore}
       keyExtractor={i => i.date}
       style={styles.list}
       contentContainerStyle={styles.listContent}
       ListEmptyComponent=<Text>You have no scoreboard yet</Text>
     <TouchableOpacity
       style={styles.button}
       onPress={() => {
         navigation.navigate(RootRoutes.UNITY, {messageToUnity: ''});
       }}>
       <Text style={styles.buttonText}>Go Unity</Text>
     </TouchableOpacity>
   </View>
 );
};
Then we go to Unity Editor.

Preparation of game

Before we set up receiving messages, let's add a new field that will display our best score.
Here we’ll add two text fields:
Best – only text;
Best Score Text в Canvas – to maintain the value of the best result.
How it looks on the screen:
Now let’s uncover what we wanna get out from message transmission to/from Unity:
1
Clicking «Save result» button must move us to the Home screen (React Native part), but before that our points should be handled from the Unity message (onUnityMessage), then processed and recorded in our stats.
2
Recording the best result in the bestScore field (just by calling our postMessage method) the next time you hit the game screen, and so on.
But before we need to understand how to send and process messages with Unity.

Message transmission by Unity

First, let's add a script and call it MessageToReactScript. Here we’ll insert the following code. You can also find it on the library’s GitHub page:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine.UI;
using UnityEngine;

public class NativeAPI {
#if UNITY_IOS && !UNITY_EDITOR
  [DllImport("__Internal")]
  public static extern void sendMessageToMobileApp(string message);
#endif
}

public class ButtonBehavior : MonoBehaviour
{
  public void ButtonPressed()
  {
    if (Application.platform == RuntimePlatform.Android)
    {
      using (AndroidJavaClass jc = new AndroidJavaClass("com.azesmwayreactnativeunity.ReactNativeUnityViewManager"))
      {
        jc.CallStatic("sendMessageToMobileApp", "The button has been tapped!");
      }
    }
    else if (Application.platform == RuntimePlatform.IPhonePlayer)
    {
#if UNITY_IOS && !UNITY_EDITOR
      NativeAPI.sendMessageToMobileApp("The button has been tapped!");
#endif
    }
  }
}
This script will help us to transmit messages from Unity.
Now let’s modify it a little bit to get the result we want:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine.UI;
using UnityEngine;

public class NativeAPI
{
#if UNITY_IOS && !UNITY_EDITOR
 [DllImport("__Internal")]
 public static extern void sendMessageToMobileApp(string message);
#endif
}

//Score class
public class Score
{

   public int score;
   public string date;
   public Score(int score, string date)
   {
       this.score = score;
       this.date = date;
   }
}

public class MessageToReactScript : MonoBehaviour
{

   private Text _score;

   public void ButtonPressed()
   {
       //getting current date in ISO format
       var date = DateTime.Now.ToString("o");
       //getting current score from UI
       _score = GameObject.FindGameObjectWithTag("Score").GetComponent();

       //creating an instance of Score class
       Score score = new(int.Parse(_score.text), date);
       //pare score object to json (we sending a string)
       string scoreJSON = JsonUtility.ToJson(score);

       print(scoreJSON);

       if (Application.platform == RuntimePlatform.Android)
       {
           using (AndroidJavaClass jc = new AndroidJavaClass("com.azesmwayreactnativeunity.ReactNativeUnityViewManager"))
           {
               //send message to android
               jc.CallStatic("sendMessageToMobileApp", scoreJSON);
           }
       }
       else if (Application.platform == RuntimePlatform.IPhonePlayer)
       {
#if UNITY_IOS && !UNITY_EDITOR
           //send message to iOS
           NativeAPI.sendMessageToMobileApp(scoreJSON);
#endif
       }
   }

}
Please note: the example demonstrates sending only one string. Therefore, we'll transmit everything in JSON format to streamline the process and avoid unnecessary complications.
After that, let’s add a new class describing our record. It has two fields – date and score. Current results with its date and time are written directly in the score. All you need to do next is to stringify the score instance in JSON and send it to the mobile app.
Our script is ready. Now we create the new GameObject ReactBridge:
Insert our script in it:
Go to Canvas and find the «Save Result» button that we’ve made the last time. Insert our ButtonPressed method from the snippet described above into it. Firstly, add the GameObject ReactBridge, select the MessageToReactScript script, and then the ButtonPressed() method itself:
When we click the «Save Result» button, we send JSON with our result to the mobile app.
The basic preparation for transmitting a message from Unity is complete. Now we need to compile and reinstall the Unity build on Android and iOS.
IMPORTANT!
In the previous part, we've already added the iOS folder in Plugins with the NativeCallProxy.mm and NativeCallProxy.h files with functions’ descriptions that we're going to call. If they are different from ones we call in MessageToReactScript or they are even missing we’ll get the error while building UnityFramework in Xcode e.g. such a method doesn’t exist at all. So, it is better to check all the matches in advance before starting.
Here you can use the files from the library repository. Just follow the link.

Messages processing from Unity in mobile

Let’s get synchronized. At the moment we have:
• Unity-project example, which transmits a message with the result to our mobile app
• Mobile app which processes the message from Unity.
How to rebuild the project and insert it into a mobile app, we've already covered in the first part of the article.
Now let’s start with processing the message. First, we’ll set an alert to make sure our message is actually transmitted when the button is clicked.
 
 const handleUnityMessage = (message: string) => {
   //alert to show Unity message data
   alert(message);
   const score = JSON.parse(message) as Score;
   console.log({score});
 };
Doing everything as above, we get the following result:
What do we see? The message appeared. It shows the details of the score and the time it was received. Now let’s put it in our scores state. But if we don’t want to lose the data let’s put in async-storage. But firstly, install the library itself.
For this enter the following commands:

yarn add @react-native-async-storage/async-storage
npx pod-install или cd ios && pod install && cd ..
It's possible that someone might want to use faster storage like mmkv, but it’s not critical. So async-storage will be enough for us.
After that, we’ll come back to React Native code. Go to the Home Screen and write the new value in the list.
Let’s rewrite the message processing in Unity this way:
 
 const handleUnityMessage = (message: string) => {
   //alert to show Unity message data
   const score = JSON.parse(message) as Score; //parse message to Score Object
   if (score) {
     unityRef.current?.unloadUnity();//unload Unity View
     navigation.navigate(RootRoutes.HOME, {score}); //going to Home Screen with recent score
   }
 };
Here we parse our JSON with the result and then go to HomeScreen, throwing the result in parameters.
We can also add this change to our navigation types (it will help us to avoid errors from typescript):

type RootStackParamList = {
 [RootRoutes.HOME]: {score?: Score}; // can get Score from Unity Screen
 [RootRoutes.UNITY]: {messageToUnity?: string}; // added messageToUnity
};
After that, we modify HomeScreen, to make it record new data in storage and state while it receives the result through navigation. Please note: in our case we only store the top 10 results in descending order. You can store and show as many as you need in any order you like.
That's what we got:
 
 //func to setup scores from async storage on app open (we have no scores)
 const setupScores = async () => {
   const scoresJSON = await AsyncStorage.getItem('scores');

   if (scoresJSON) {
     setScores(JSON.parse(scoresJSON) as Score[]);
   }
 };

 //setting up existed scores
 useEffect(() => {
   if (!scores.length) {
     setupScores();
   }
 });

 const setNewScores = async (score: Score) => {
   //creating new scores with new one, includes filter & sort to show only 10 best results
   const newScores = [...scores, score]
     .sort((a, b) => b.score - a.score)
     .slice(0, 10);

   //setting new scores to async storage
   await AsyncStorage.setItem('scores', JSON.stringify(newScores));

   //setting new scores to scores' state
   setScores(newScores);

   //clean navigation score param
   navigation.setParams({score: undefined});
 };

 useEffect(() => {
   if (route.params?.score) {
     setNewScores(route.params.score);
   }
 }, [route.params]);
In this block of code we make following:
1
Reviewing and recording our past results (if they are available).
2
Check in the useEffect for available data (in scores). If it is available, write previous data stored in the cache.
3
Saving new results from navigation, and cleaning the parameters.
4
Check the useEffect to see if a new result appears in the parameters, and overwrite if so.
We also made some UI changes: added date processing to avoid ISO data format (doesn't look very good, don't you think?) and slightly changed the styles. You can do the same or change the interface however you like.
Here is the final result:
Now you can rebuild it for Android and check that everything is working the way it should:

Most of our functionality is ready to go, so we can safely transmit the data from Unity to React Native. Now it’s time to work with one more important task – organize data transfer from React Native to Unity.

Messages processing from React Native to Unity

Surprise-surprise! It turned out that we have already had a block of code that was ready for sending messages using React Native:

const {messageToUnity} = route.params;

 useEffect(() => {
   if (messageToUnity) {
     unityRef.current?.postMessage('', '', messageToUnity);// right here
   }
 }, [messageToUnity]);
Here we must check whether the navigation parameters contain messageToUnity. If there is one, call postMessage function. As we can see from the description it can take in the following arguments:
 (gameObject: string, methodName: string, message: string)
Therefore, we need to paste here our game object, specify its function and message.
Let’s use the LogicManager object for this and add a new function to process this message.
Then we'll switch to Unity and make the final changes: add a new line with BestScore to the LogicManagerScript, and then create the function itself that will change its value:

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using TMPro;

public class LogicManagerScript : MonoBehaviour
{
   private int _score;
   [SerializeField]
   private Text _scoreText;
   [SerializeField]
   private GameObject _gameOverScreen;
   //best score field where we write value from RN part
   public TextMeshProUGUI bestScoreText;
   [SerializeField]
   private GameObject _startScreen;
   [SerializeField]
   private GameObject _game;

   [ContextMenu("Increase Score")]
   public void IncreaseScore(int number)
   {
       _score += number;
       _scoreText.text = _score.ToString();
   }

   //rewrite bestScoreText value with RN message
   public void SetBestScore(string message)
   {
       bestScoreText.text = message;
   }

   public void RestartGame()
   {
       SceneManager.LoadScene(SceneManager.GetActiveScene().name);
   }

   public void GameOver()
   {
       _gameOverScreen.SetActive(true);
   }

   public void StartGame()
   {
       _startScreen.SetActive(false);
       _game.SetActive(true);
   }
}
Here we’ve added:
bestScoreText – text we get from the UI;
• public void SetBestScore – function, which takes and places in the text value bestScoreText message, transmitted from React Native via the postMessage function.
After that, in Unity Editor put the text element to LogicManagerScript (which is located inside GameObject LogicManager ), to make the value of our BestScoreText changeable.
And this is where our work in Unity ends. But you can always add something else or improve the written code.
The only thing you should do is to rebuild again.
Come back to React Native. First, we change our switching to the Unity page function, so that we can take the best results and send them to the game, where we have them.
Add the following function to HomeScreen:

const goUnity = () => {
   let messageToUnity = '0';// set default value to 0
   if (scores.length) {
// if we have scores select max value
// element with 0 index is the highest because we’ve sorted our scores
     messageToUnity = scores[0].score.toString(); 
   }
//go to Unity with max score = messageToUnity
   navigation.navigate(RootRoutes.UNITY, {messageToUnity});
 };
After that, our Unity screen will always wait for the message. Then we put this function to onPress of our «Go Unity» button:

 <TouchableOpacity style={styles.button} onPress={goUnity}>//added a method
       <Text style={styles.buttonText}>Go Unity</Text>
     </TouchableOpacity>
Add the values to the function for posting messages to Unity. On UnityScreen change or add the following part:
 
 const {messageToUnity} = route.params; // getting our message from route params
//creating message object (not necessary)
 const message = {
   gameObject: 'LogicManager',
   method: 'SetBestScore',
   message: messageToUnity,
 };
//on getting message from route posting it to Unity
 useEffect(() => {
   if (messageToUnity) {
     unityRef.current?.postMessage(
       message.gameObject,
       message.method,
       message.message,
     );
   }
 }, [messageToUnity]);
In that case, we specify that the gameObject and methodName to which we transmit our message are LogicManager and SetBestScore, respectively. At the same time, our message stores the best result posted by the HomeScreen (our message is a line that can store not only text but also JSON objects).
Let's run the apps on iOS and Android and see if everything works:
iOS:
Android:
Yep! Everything works 🙌
Important!
While transmitting a message to Unity, we've faced a little problem: nothing happened after sending. The answer was on the Unity home screen. As this was a separate scene, there was no LogicManager and our text was not displayed. So we’ve changed the code a little and placed the game scenes on the Canvas home screen. Not the best solution, but it worked for us.

So when you call the postMessage function, make sure that the correct scene is open.

Summary

Our two in-depth articles uncover the integration of a Unity project into an application written with React Native. So here's a step-by-step guidance we've been working with ourselves. We hope that our experience will make it easier to solve similar problems or inspire you on new projects. You can also learn a bit more about the process by reading about plugins in Unity.
All code examples can be found at the links below.

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import {
  FlatList,
  ListRenderItem,
  StatusBar,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from 'react-native';
import {
  SafeAreaProvider,
  useSafeAreaInsets,
} from 'react-native-safe-area-context';
import UnityView from '@azesmway/react-native-unity';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';

enum RootRoutes {
  HOME = 'Home',
  UNITY = 'Unity',
}

type RootStackParamList = {
  [RootRoutes.HOME]: { score?: Score };
  [RootRoutes.UNITY]: { messageToUnity: string }; // added messageToUnity
};

type RootStackScreenProps =
  NativeStackScreenProps;

const Stack = createNativeStackNavigator();

// score data type
type Score = {
  date: string;
  score: number;
};

const HomeScreen: React.FC> = ({
  navigation,
  route,
}) => {
  const [scores, setScores] = useState([]); // scores to display in list

  const insets = useSafeAreaInsets();

  // func to setup scores from async storage on app open (we have no scores)
  const setupScores = async () => {
    const scoresJSON = await AsyncStorage.getItem('scores');

    if (scoresJSON) {
      setScores(JSON.parse(scoresJSON) as Score[]);
    }
  };

  // setting up existed scores
  useEffect(() => {
    if (!scores.length) {
      setupScores();
    }
  }, []);

  const setNewScores = async (score: Score) => {
    // creating new scores with new one, includes filter & sort to show only 10 best results
    const newScores = [...scores, score]
      .sort((a, b) => b.score - a.score)
      .slice(0, 10);

    // setting new scores to async storage
    await AsyncStorage.setItem('scores', JSON.stringify(newScores));

    // setting new scores to scores' state
    setScores(newScores);

    // clean navigation score param
    navigation.setParams({ score: undefined });
  };

  useEffect(() => {
    if (route.params?.score) {
      setNewScores(route.params.score);
    }
  }, [route.params]);

  const goUnity = () => {
    let messageToUnity = '0';
    if (scores.length) {
      messageToUnity = scores[0].score.toString();
    }
    navigation.navigate(RootRoutes.UNITY, { messageToUnity });
  };

  // List item to render
  const renderScore: ListRenderItem = useCallback(({ item, index }) => {
    return (
      <View style={styles.score}>
        <Text style={styles.scoreText}>{index + 1}.</Text>
        <Text style={[styles.scoreText, styles.flex]}>{item.score}</Text>
        <Text style={styles.scoreDate}>{new Date(item.date).toLocaleString()}</Text>
      </View>
    );
  }, []);

  return (
    <View style={[styles.screen, { paddingBottom: Math.max(insets.bottom, 15) }]}>
      <Text style={styles.welcomeText}>
        Hello, from{' '}
        <Text style={[styles.welcomeText, styles.purple]}>dev.family</Text> team
      </Text>
      {/* scoreboard */}
      <Text style={styles.welcomeText}>Scores 🏆:</Text>
      {!!scores.length && (
        <View style={[styles.row, styles.scoreInfo]}>
          <Text style={[styles.scoreText, styles.flex]}>Score</Text>
          <Text style={styles.scoreText}>Date</Text>
        </View>
      )}
      <FlatList
        data={scores}
        renderItem={renderScore}
        keyExtractor={(i) => i.date}
        style={styles.list}
        contentContainerStyle={styles.listContent}
        ListEmptyComponent={<Text>You have no scoreboard yet</Text>}
      />
      <TouchableOpacity style={styles.button} onPress={goUnity}>
        <Text style={styles.buttonText}>Go Unity</Text>
      </TouchableOpacity>
    </View>
  );
};

const UnityScreen: React.FC> = ({ route, navigation }) => {
  // Start
  const unityRef = useRef<UnityView>(null);

  const { messageToUnity } = route.params;

  const message = {
    gameObject: 'LogicManager',
    method: 'SetBestScore',
    message: messageToUnity,
  };

  useEffect(() => {
    if (messageToUnity) {
      unityRef.current?.postMessage(message.gameObject, message.method, message.message);
    }
  }, [messageToUnity]);

  const handleUnityMessage = (json: string) => {
    // alert to show Unity message data
    const score = JSON.parse(json) as Score;
    if (score) {
      // unityRef.current?.unloadUnity();
      navigation.navigate(RootRoutes.HOME, { score });
      unityRef.current?.unloadUnity();
    }
  };

  // End

  return (
    <View style={styles.flex}>
      <UnityView
        ref={unityRef}
        //@ts-expect-error UnityView needs a 'flex: 1' style to show full screen view
        style={styles.flex}
        onUnityMessage={(e) => handleUnityMessage(e.nativeEvent.message)} // and this line
      />
    </View>
  );
};

const App = () => {
  return (
    <View style={styles.flex}>
      <StatusBar backgroundColor={'#FFF'} barStyle="dark-content" />

      <SafeAreaProvider>
        <NavigationContainer>
          
            <Stack.Screen name={RootRoutes.HOME} component={HomeScreen} />
            <Stack.Screen name={RootRoutes.UNITY} component={UnityScreen} />
          </Stack.Navigator>
        </NavigationContainer>
      </SafeAreaProvider>
    </View>
  );
};

const styles = StyleSheet.create({
  screen: {
    flex: 1,
    paddingHorizontal: 16,
    gap: 30,
    paddingTop: 25,
  },
  button: {
    width: '100%',
    backgroundColor: 'purple',
    justifyContent: 'center',
    alignItems: 'center',
    height: 50,
    borderRadius: 16,
    marginTop: 'auto',
  },
  purple: { color: 'purple' },
  buttonText: {
    color: '#FFF',
    fontSize: 16,
    fontWeight: '600',
  },
  welcomeText: {
    fontSize: 24,
    color: 'black',
    fontWeight: '600',
  },
  flex: {
    flex: 1,
  },
  row: { flexDirection: 'row' },
  scoreInfo: { paddingHorizontal: 14 },

  score: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
    paddingBottom: 6,
    borderBottomWidth: 1,
    borderColor: '#bcbcbc',
  },
  scoreText: {
    fontSize: 18,
    fontWeight: '500',
    color: 'black',
  },
  scoreDate: {
    color: '#262626',
    fontSize: 16,
    fontWeight: '400',
  },
  list: {
    flex: 1,
  },
  listContent: {
    flexGrow: 1,
    paddingBottom: 20,
    gap: 12,
  },
});

export default App;
It was the dev.family team on line, and see you later ;)

Links

• The repository where you can view the code of the mobile application: here.
• The repository where you can find the game code: here.
• Link to the library: here.