@dolami-inc/react-native-expo-unity 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fahrezi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # react-native-expo-unity
2
+
3
+ Unity as a Library (UaaL) bridge for React Native / Expo.
4
+
5
+ > ⚠️ **iOS only** — Android support is coming soon.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install react-native-expo-unity
11
+ # or
12
+ yarn add react-native-expo-unity
13
+ # or
14
+ bun add react-native-expo-unity
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```tsx
20
+ import { UnityView, type UnityViewRef } from "react-native-expo-unity";
21
+
22
+ const unityRef = useRef<UnityViewRef>(null);
23
+
24
+ <UnityView
25
+ ref={unityRef}
26
+ style={{ flex: 1 }}
27
+ onUnityMessage={(e) => console.log(e.message)}
28
+ />
29
+
30
+ // Send message to Unity
31
+ unityRef.current?.postMessage("GameObject", "Method", "payload");
32
+ ```
33
+
34
+ ## API
35
+
36
+ ### `<UnityView />`
37
+
38
+ | Prop | Type | Default | Description |
39
+ |---|---|---|---|
40
+ | `onUnityMessage` | `(e: { message: string }) => void` | — | Message from Unity |
41
+ | `autoUnloadOnUnmount` | `boolean` | `true` | Unload Unity when view unmounts. Set `false` to pause only (keeps state). |
42
+ | `style` | `ViewStyle` | — | Must have dimensions (e.g. `flex: 1`) |
43
+ | `ref` | `UnityViewRef` | — | Imperative methods |
44
+
45
+ ### Ref Methods
46
+
47
+ ```tsx
48
+ unityRef.current?.postMessage(gameObject, methodName, message)
49
+ unityRef.current?.pauseUnity()
50
+ unityRef.current?.resumeUnity()
51
+ unityRef.current?.unloadUnity()
52
+ ```
53
+
54
+ ### Standalone Functions
55
+
56
+ Same as ref methods, callable anywhere (operates on the singleton):
57
+
58
+ ```tsx
59
+ import { postMessage, pauseUnity, resumeUnity, unloadUnity, isInitialized } from "react-native-expo-unity";
60
+ ```
61
+
62
+ ## Setup
63
+
64
+ ### 1. Unity project — add plugin
65
+
66
+ Copy the plugin files into your Unity project:
67
+
68
+ ```bash
69
+ # From node_modules after install
70
+ cp node_modules/react-native-expo-unity/plugin/NativeCallProxy.h <UnityProject>/Assets/Plugins/iOS/
71
+ cp node_modules/react-native-expo-unity/plugin/NativeCallProxy.mm <UnityProject>/Assets/Plugins/iOS/
72
+ ```
73
+
74
+ ### 2. Unity project — build iOS
75
+
76
+ 1. Unity → File → Build Settings → iOS → Build
77
+ 2. Open generated Xcode project
78
+ 3. Select `NativeCallProxy.h` in Libraries/Plugins/iOS/
79
+ 4. Set Target Membership → `UnityFramework` → **Public**
80
+ 5. Build `UnityFramework` scheme
81
+
82
+ ### 3. Copy build artifacts to your RN project
83
+
84
+ Create `unity/builds/ios/` in your project root and copy:
85
+
86
+ ```bash
87
+ mkdir -p unity/builds/ios
88
+ cp -R <unity-build>/UnityFramework.framework unity/builds/ios/
89
+ cp <unity-build>/{baselib.a,il2cpp.a,libGameAssembly.a} unity/builds/ios/
90
+ ```
91
+
92
+ > Custom path? Set `EXPO_UNITY_PATH` environment variable to your Unity build directory.
93
+
94
+ ### 4. Add the config plugin to `app.json`
95
+
96
+ ```json
97
+ {
98
+ "expo": {
99
+ "plugins": [
100
+ "react-native-expo-unity"
101
+ ]
102
+ }
103
+ }
104
+ ```
105
+
106
+ The plugin automatically configures the required Xcode build settings:
107
+ - `ENABLE_BITCODE = NO` — Unity does not support bitcode
108
+ - `CLANG_CXX_LANGUAGE_STANDARD = c++17` — required for Unity headers
109
+ - `FRAMEWORK_SEARCH_PATHS` — adds the Unity build artifacts directory
110
+
111
+ If your Unity artifacts are in a custom path, pass the option:
112
+
113
+ ```json
114
+ ["react-native-expo-unity", { "unityPath": "/absolute/path/to/unity/builds/ios" }]
115
+ ```
116
+
117
+ ### 5. Build
118
+
119
+ ```bash
120
+ expo prebuild --platform ios --clean
121
+ expo run:ios --device
122
+ ```
123
+
124
+ ## Lifecycle
125
+
126
+ Unity is a **singleton** — one instance for the entire app.
127
+
128
+ | State | Memory | Re-entry |
129
+ |---|---|---|
130
+ | Running | ~200-500MB+ (depends on scene/assets) | Already running |
131
+ | Paused | Same (frozen in memory, no CPU/GPU usage) | `resumeUnity()` — instant, state preserved |
132
+ | Unloaded | ~80-180MB retained (Unity limitation) | Remount `<UnityView />` — ~1-2s reinit, state reset |
133
+
134
+ ### Auto behavior
135
+
136
+ | Event | What happens |
137
+ |---|---|
138
+ | `<UnityView />` mounts | Unity initializes and starts rendering |
139
+ | `<UnityView />` unmounts | Unity unloads (or pauses if `autoUnloadOnUnmount={false}`) |
140
+ | App → background | Unity pauses |
141
+ | App → foreground | Unity resumes |
142
+
143
+ ### Manual control
144
+
145
+ Screen focus/blur is **not** automatic — handle with `useFocusEffect`:
146
+
147
+ ```tsx
148
+ useFocusEffect(
149
+ useCallback(() => {
150
+ unityRef.current?.resumeUnity();
151
+ return () => unityRef.current?.pauseUnity();
152
+ }, [])
153
+ );
154
+ ```
155
+
156
+ ## Messaging
157
+
158
+ ### RN → Unity
159
+
160
+ ```tsx
161
+ unityRef.current?.postMessage("GameManager", "LoadAvatar", '{"id":"avatar_01"}');
162
+ ```
163
+
164
+ ```csharp
165
+ // Unity C# — on "GameManager" GameObject
166
+ public void LoadAvatar(string json) { /* ... */ }
167
+ ```
168
+
169
+ ### Unity → RN
170
+
171
+ ```csharp
172
+ #if UNITY_IOS && !UNITY_EDITOR
173
+ [DllImport("__Internal")]
174
+ private static extern void sendMessageToMobileApp(string message);
175
+ #endif
176
+
177
+ // Recommended: JSON format
178
+ sendMessageToMobileApp("{\"event\":\"image_taken\",\"data\":{\"path\":\"/tmp/photo.jpg\"}}");
179
+ ```
180
+
181
+ ```tsx
182
+ <UnityView onUnityMessage={(e) => {
183
+ const msg = JSON.parse(e.message);
184
+ // msg.event, msg.data
185
+ }} />
186
+ ```
187
+
188
+ > See [Messaging Guide](docs/messaging.md) for recommended patterns.
189
+
190
+ ## Docs
191
+
192
+ - [Lifecycle Deep Dive](docs/lifecycle.md) — navigation scenarios, state management, trade-offs
193
+ - [Messaging Guide](docs/messaging.md) — recommended JSON format, Unity C# + RN examples
194
+
195
+ ## Requirements
196
+
197
+ - **Expo SDK 54+**
198
+ - **React Native New Architecture** (Fabric) — old architecture not supported
199
+ - **Physical iOS device** — Unity renders only on device; Simulator shows a placeholder view
200
+ - **Unity build artifacts** — must be copied manually into your project (~2GB, not bundled via npm)
201
+
202
+ ## Platform Support
203
+
204
+ | Platform | Status |
205
+ |---|---|
206
+ | iOS Device | ✅ Supported |
207
+ | iOS Simulator | ⚠️ Not supported — renders a placeholder view |
208
+ | Android | 🚧 Coming soon |
209
+
210
+ ## Limitations
211
+
212
+ - **Single instance** — only one Unity view at a time, cannot run multiple
213
+ - **Full-screen rendering only** — Unity renders full-screen within its view (Unity limitation)
214
+ - **Memory retention** — after `unloadUnity()`, Unity retains 80-180MB in memory (Unity limitation)
215
+ - **No reload after quit** — if Unity calls `Application.Quit()` on iOS, it cannot be restarted without restarting the app
216
+ - **No hot reload** — native code changes require a full rebuild
217
+
218
+ ## License
219
+
220
+ MIT
package/app.plugin.js ADDED
@@ -0,0 +1,82 @@
1
+ // @ts-check
2
+ const { withXcodeProject } = require('@expo/config-plugins');
3
+
4
+ /**
5
+ * Expo Config Plugin for react-native-expo-unity.
6
+ *
7
+ * Automatically injects the Xcode build settings required for
8
+ * Unity as a Library (UaaL) to link and run correctly.
9
+ *
10
+ * @param {import('@expo/config-plugins').ExpoConfig} config
11
+ * @param {{ unityPath?: string }} options
12
+ * unityPath — path to the Unity iOS build artifacts directory.
13
+ * Defaults to `$(PROJECT_DIR)/unity/builds/ios`.
14
+ * Can also be set via the EXPO_UNITY_PATH environment variable.
15
+ */
16
+ const withExpoUnity = (config, options = {}) => {
17
+ return withXcodeProject(config, (config) => {
18
+ const xcodeProject = config.modResults;
19
+
20
+ const unityPath =
21
+ options.unityPath ||
22
+ process.env.EXPO_UNITY_PATH ||
23
+ '$(PROJECT_DIR)/unity/builds/ios';
24
+
25
+ const configurations = xcodeProject.pbxXCBuildConfigurationSection();
26
+
27
+ for (const key of Object.keys(configurations)) {
28
+ const configuration = configurations[key];
29
+ if (typeof configuration !== 'object' || !configuration.buildSettings) {
30
+ continue;
31
+ }
32
+
33
+ const settings = configuration.buildSettings;
34
+
35
+ // Unity as a Library does not support bitcode.
36
+ settings['ENABLE_BITCODE'] = 'NO';
37
+
38
+ // Unity headers require C++17.
39
+ settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++17';
40
+
41
+ // Add the Unity framework directory to the search paths so that
42
+ // UnityFramework.framework can be found at build time.
43
+ addFrameworkSearchPath(settings, unityPath);
44
+ }
45
+
46
+ return config;
47
+ });
48
+ };
49
+
50
+ /**
51
+ * Appends `unityPath` to FRAMEWORK_SEARCH_PATHS without duplicating it.
52
+ *
53
+ * The xcode npm package stores this setting as either:
54
+ * - undefined (not set)
55
+ * - a plain string: `"$(inherited)"`
56
+ * - a parenthesised list: `("$(inherited)", "/some/path")`
57
+ *
58
+ * @param {Record<string, any>} settings
59
+ * @param {string} unityPath
60
+ */
61
+ function addFrameworkSearchPath(settings, unityPath) {
62
+ const quoted = `"${unityPath}"`;
63
+ const existing = settings['FRAMEWORK_SEARCH_PATHS'];
64
+
65
+ if (!existing) {
66
+ settings['FRAMEWORK_SEARCH_PATHS'] = `(${quoted}, "$(inherited)")`;
67
+ return;
68
+ }
69
+
70
+ const asStr = String(existing);
71
+
72
+ // Already present — nothing to do.
73
+ if (asStr.includes(unityPath)) {
74
+ return;
75
+ }
76
+
77
+ // Strip surrounding parens if present, then rebuild.
78
+ const inner = asStr.replace(/^\(|\)$/g, '').trim();
79
+ settings['FRAMEWORK_SEARCH_PATHS'] = `(${quoted}, ${inner})`;
80
+ }
81
+
82
+ module.exports = withExpoUnity;
@@ -0,0 +1,131 @@
1
+ # Lifecycle Deep Dive
2
+
3
+ ## Unity States
4
+
5
+ ```
6
+ Not Initialized ──mount──→ Running ──pause──→ Paused
7
+ ↑ │
8
+ └────resume─────────┘
9
+
10
+ unload
11
+
12
+
13
+ Unloaded ──mount──→ Running (fresh state)
14
+ ```
15
+
16
+ - **Pause/Resume** — cheap, instant, state preserved (avatar, scene, camera position)
17
+ - **Unload** — frees most memory, but ~80-180MB retained by Unity. Next mount = full reinit (~1-2s), state reset.
18
+
19
+ ## Navigation Scenarios
20
+
21
+ Stack: `Gallery → AR Camera → Add Caption`
22
+
23
+ ### Gallery → AR Camera (push)
24
+
25
+ | What happens | Detail |
26
+ |---|---|
27
+ | AR Camera mounts | `<UnityView />` renders |
28
+ | Unity initializes | First time: ~1-2s. If paused (not unloaded): instant resume |
29
+
30
+ ### AR Camera → Add Caption (push)
31
+
32
+ | What happens | Detail |
33
+ |---|---|
34
+ | AR Camera stays mounted | React Navigation keeps stack screens alive |
35
+ | AR Camera loses focus | `useFocusEffect` cleanup fires |
36
+ | You call `pauseUnity()` | Unity freezes, state preserved |
37
+
38
+ ### Add Caption → AR Camera (back)
39
+
40
+ | What happens | Detail |
41
+ |---|---|
42
+ | AR Camera regains focus | `useFocusEffect` fires |
43
+ | You call `resumeUnity()` | Unity resumes instantly, all state preserved |
44
+
45
+ ### AR Camera → Gallery (back)
46
+
47
+ | What happens | Detail |
48
+ |---|---|
49
+ | AR Camera unmounts | Component removed from stack |
50
+ | `autoUnloadOnUnmount=true` | Unity unloads, memory freed, state reset |
51
+ | `autoUnloadOnUnmount=false` | Unity pauses only, state preserved, memory stays |
52
+
53
+ ## Patterns
54
+
55
+ ### Pattern A: Fresh state every time
56
+
57
+ Unity resets when user leaves. Safest, but slower re-entry.
58
+
59
+ ```tsx
60
+ <UnityView
61
+ ref={unityRef}
62
+ style={{ flex: 1 }}
63
+ autoUnloadOnUnmount={true} // default
64
+ onUnityMessage={handleMessage}
65
+ />
66
+ ```
67
+
68
+ ```tsx
69
+ useFocusEffect(
70
+ useCallback(() => {
71
+ unityRef.current?.resumeUnity();
72
+ return () => unityRef.current?.pauseUnity();
73
+ }, [])
74
+ );
75
+ ```
76
+
77
+ ### Pattern B: Keep state alive
78
+
79
+ Unity stays in memory for instant re-entry. Better UX, higher memory.
80
+
81
+ ```tsx
82
+ <UnityView
83
+ ref={unityRef}
84
+ style={{ flex: 1 }}
85
+ autoUnloadOnUnmount={false} // pause instead of unload
86
+ onUnityMessage={handleMessage}
87
+ />
88
+ ```
89
+
90
+ ```tsx
91
+ useFocusEffect(
92
+ useCallback(() => {
93
+ unityRef.current?.resumeUnity();
94
+ return () => unityRef.current?.pauseUnity();
95
+ }, [])
96
+ );
97
+ ```
98
+
99
+ ### Pattern C: Force reset on blur
100
+
101
+ Guarantee fresh state even if the component doesn't unmount.
102
+
103
+ ```tsx
104
+ useFocusEffect(
105
+ useCallback(() => {
106
+ unityRef.current?.resumeUnity();
107
+ return () => unityRef.current?.unloadUnity(); // force unload
108
+ }, [])
109
+ );
110
+ ```
111
+
112
+ ## Memory
113
+
114
+ | State | Typical memory |
115
+ |---|---|
116
+ | App without Unity | ~100-200MB |
117
+ | Unity running | ~200-500MB+ (depends on scene/assets) |
118
+ | Unity paused | Same as running (frozen in RAM) |
119
+ | Unity unloaded | ~80-180MB retained (Unity limitation) |
120
+
121
+ **Paused Unity does not use CPU/GPU.** No battery drain. Only concern is memory pressure — iOS may kill your app if system memory is low.
122
+
123
+ ## Auto vs Manual
124
+
125
+ | Behavior | Handled by | You need to |
126
+ |---|---|---|
127
+ | Initialize on mount | Auto | Nothing |
128
+ | Unload/pause on unmount | Auto (configurable via `autoUnloadOnUnmount`) | Nothing |
129
+ | App background/foreground | Auto (pause/resume) | Nothing |
130
+ | Screen focus/blur | **Manual** | Use `useFocusEffect` |
131
+ | Force unload mid-session | **Manual** | Call `unloadUnity()` |
@@ -0,0 +1,117 @@
1
+ # Messaging Guide
2
+
3
+ The bridge passes raw strings between Unity and React Native. You can use any format, but we recommend **JSON** for consistency.
4
+
5
+ ## Recommended Format
6
+
7
+ All messages as JSON with `event` + `data`:
8
+
9
+ ```json
10
+ {
11
+ "event": "event_name",
12
+ "data": { ... }
13
+ }
14
+ ```
15
+
16
+ ### Unity → RN Examples
17
+
18
+ ```json
19
+ { "event": "unity_ready", "data": {} }
20
+ { "event": "image_taken", "data": { "path": "/tmp/photo.jpg", "w": 828, "h": 1792 } }
21
+ { "event": "session_close", "data": { "reason": "user_request" } }
22
+ { "event": "avatar_loaded", "data": { "id": "avatar_01", "name": "Cat" } }
23
+ { "event": "error", "data": { "code": "CAMERA_DENIED", "message": "Camera permission denied" } }
24
+ ```
25
+
26
+ ### RN → Unity Examples
27
+
28
+ ```json
29
+ { "event": "load_avatar", "data": { "id": "avatar_01" } }
30
+ { "event": "set_config", "data": { "quality": "high", "ar_enabled": true } }
31
+ { "event": "take_photo", "data": {} }
32
+ ```
33
+
34
+ ## Unity C# Implementation
35
+
36
+ ```csharp
37
+ using System.Runtime.InteropServices;
38
+ using UnityEngine;
39
+
40
+ public static class RNBridge
41
+ {
42
+ #if UNITY_IOS && !UNITY_EDITOR
43
+ [DllImport("__Internal")]
44
+ private static extern void sendMessageToMobileApp(string message);
45
+ #endif
46
+
47
+ /// Send a structured event to React Native
48
+ public static void SendEvent(string eventName, object data = null)
49
+ {
50
+ var msg = new EventMessage { @event = eventName, data = data };
51
+ string json = JsonUtility.ToJson(msg);
52
+
53
+ #if UNITY_IOS && !UNITY_EDITOR
54
+ sendMessageToMobileApp(json);
55
+ #endif
56
+ }
57
+
58
+ [System.Serializable]
59
+ private class EventMessage
60
+ {
61
+ public string @event;
62
+ public object data;
63
+ }
64
+ }
65
+
66
+ // Usage:
67
+ // RNBridge.SendEvent("unity_ready");
68
+ // RNBridge.SendEvent("image_taken", new { path = "/tmp/photo.jpg", w = 828, h = 1792 });
69
+ ```
70
+
71
+ ## React Native Implementation
72
+
73
+ ```tsx
74
+ import { UnityView } from "react-native-expo-unity";
75
+
76
+ interface UnityEvent<T = unknown> {
77
+ event: string;
78
+ data: T;
79
+ }
80
+
81
+ function parseUnityMessage<T = unknown>(raw: string): UnityEvent<T> | null {
82
+ try {
83
+ return JSON.parse(raw);
84
+ } catch {
85
+ console.warn("[Unity] Invalid message:", raw);
86
+ return null;
87
+ }
88
+ }
89
+
90
+ // Usage:
91
+ <UnityView
92
+ onUnityMessage={(e) => {
93
+ const msg = parseUnityMessage(e.message);
94
+ if (!msg) return;
95
+
96
+ switch (msg.event) {
97
+ case "unity_ready":
98
+ console.log("Unity is ready");
99
+ break;
100
+ case "image_taken":
101
+ handleImageTaken(msg.data as { path: string; w: number; h: number });
102
+ break;
103
+ case "error":
104
+ handleError(msg.data as { code: string; message: string });
105
+ break;
106
+ }
107
+ }}
108
+ />
109
+ ```
110
+
111
+ ## Why JSON?
112
+
113
+ - Consistent structure for all messages
114
+ - Easy to parse on both sides
115
+ - Type-safe with interfaces/classes
116
+ - Extensible — add fields without breaking existing handlers
117
+ - Debuggable — readable in logs
@@ -0,0 +1,7 @@
1
+ {
2
+ "platforms": ["ios"],
3
+ "ios": {
4
+ "modules": ["ExpoUnityModule"],
5
+ "appDelegateSubscribers": ["ExpoUnityAppDelegateSubscriber"]
6
+ }
7
+ }
@@ -0,0 +1,52 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ # Resolve the Unity build artifacts directory.
6
+ # By default, looks for `unity/builds/ios/` in the app's project root.
7
+ # Override with EXPO_UNITY_PATH environment variable.
8
+ unity_ios_dir = ENV['EXPO_UNITY_PATH'] || File.join(Pod::Config.instance.project_root.to_s, 'unity', 'builds', 'ios')
9
+
10
+ Pod::Spec.new do |s|
11
+ s.name = 'ExpoUnity'
12
+ s.version = package['version']
13
+ s.summary = package['description']
14
+ s.description = package['description']
15
+ s.license = package['license']
16
+ s.author = package['author']
17
+ s.homepage = package['homepage']
18
+ s.platforms = { :ios => '15.1' }
19
+ s.source = { :git => package['repository']['url'], :tag => "v#{s.version}" }
20
+ s.static_framework = true
21
+
22
+ s.dependency 'ExpoModulesCore'
23
+
24
+ s.source_files = '**/*.{h,m,mm,swift}'
25
+ s.exclude_files = 'UnityFramework.framework/**/*'
26
+
27
+ # Copy Unity build artifacts from the app's project into the pod at install time
28
+ s.prepare_command = <<-CMD
29
+ if [ -d "#{unity_ios_dir}" ]; then
30
+ cp -R "#{unity_ios_dir}/UnityFramework.framework" . 2>/dev/null || true
31
+ fi
32
+ CMD
33
+
34
+ # Link UnityFramework if present
35
+ if File.exist?(File.join(unity_ios_dir, 'UnityFramework.framework'))
36
+ s.vendored_frameworks = 'UnityFramework.framework'
37
+ end
38
+
39
+ s.pod_target_xcconfig = {
40
+ 'HEADER_SEARCH_PATHS' => [
41
+ '"${PODS_TARGET_SRCROOT}/UnityFramework.framework/Headers"',
42
+ "\"#{unity_ios_dir}/UnityFramework.framework/Headers\""
43
+ ].join(' '),
44
+ 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++17',
45
+ 'GCC_PREPROCESSOR_DEFINITIONS' => 'UNITY_FRAMEWORK=1',
46
+ 'ENABLE_BITCODE' => 'NO'
47
+ }
48
+
49
+ s.user_target_xcconfig = {
50
+ 'ENABLE_BITCODE' => 'NO'
51
+ }
52
+ end
@@ -0,0 +1,36 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ // Handles app lifecycle events for Unity
5
+ // Auto pause/resume when app goes to background/foreground
6
+ public class ExpoUnityAppDelegateSubscriber: ExpoAppDelegateSubscriber {
7
+ // Track if Unity was running before going to background
8
+ // so we don't resume if it was already paused by the developer
9
+ private static var wasRunningBeforeBackground = false
10
+
11
+ public func applicationWillResignActive(_ application: UIApplication) {
12
+ let bridge = UnityBridge.shared()
13
+ if bridge.isInitialized() {
14
+ ExpoUnityAppDelegateSubscriber.wasRunningBeforeBackground = true
15
+ bridge.pause(true)
16
+ NSLog("[ExpoUnity] Auto-paused (app entering background)")
17
+ } else {
18
+ ExpoUnityAppDelegateSubscriber.wasRunningBeforeBackground = false
19
+ }
20
+ }
21
+
22
+ public func applicationDidBecomeActive(_ application: UIApplication) {
23
+ if ExpoUnityAppDelegateSubscriber.wasRunningBeforeBackground {
24
+ UnityBridge.shared().pause(false)
25
+ ExpoUnityAppDelegateSubscriber.wasRunningBeforeBackground = false
26
+ NSLog("[ExpoUnity] Auto-resumed (app entering foreground)")
27
+ }
28
+ }
29
+
30
+ public func applicationWillTerminate(_ application: UIApplication) {
31
+ if UnityBridge.shared().isInitialized() {
32
+ UnityBridge.shared().unload()
33
+ NSLog("[ExpoUnity] Auto-unloaded (app terminating)")
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,39 @@
1
+ import ExpoModulesCore
2
+
3
+ public class ExpoUnityModule: Module {
4
+ public func definition() -> ModuleDefinition {
5
+ Name("ExpoUnity")
6
+
7
+ // Send a message from RN to Unity
8
+ Function("postMessage") { (gameObject: String, methodName: String, message: String) in
9
+ UnityBridge.shared().sendMessage(gameObject, methodName: methodName, message: message)
10
+ }
11
+
12
+ // Pause / resume Unity
13
+ Function("pauseUnity") { (pause: Bool) in
14
+ UnityBridge.shared().pause(pause)
15
+ }
16
+
17
+ // Unload Unity (free memory)
18
+ Function("unloadUnity") {
19
+ UnityBridge.shared().unload()
20
+ }
21
+
22
+ // Check if Unity is initialized
23
+ Function("isInitialized") { () -> Bool in
24
+ return UnityBridge.shared().isInitialized()
25
+ }
26
+
27
+ // Event sent from Unity → RN
28
+ Events("onUnityMessage")
29
+
30
+ // The native Unity view
31
+ View(ExpoUnityView.self) {
32
+ Events("onUnityMessage")
33
+
34
+ Prop("autoUnloadOnUnmount") { (view: ExpoUnityView, value: Bool) in
35
+ view.autoUnloadOnUnmount = value
36
+ }
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,115 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ class ExpoUnityView: ExpoView {
5
+ private let onUnityMessage = EventDispatcher()
6
+ var autoUnloadOnUnmount: Bool = true
7
+
8
+ required init(appContext: AppContext? = nil) {
9
+ super.init(appContext: appContext)
10
+
11
+ #if targetEnvironment(simulator)
12
+ setupSimulatorPlaceholder()
13
+ #else
14
+ DispatchQueue.main.async { [weak self] in
15
+ self?.setupUnity()
16
+ }
17
+ #endif
18
+ }
19
+
20
+ required init?(coder: NSCoder) {
21
+ fatalError("init(coder:) has not been implemented")
22
+ }
23
+
24
+ // MARK: - Simulator
25
+
26
+ #if targetEnvironment(simulator)
27
+ private func setupSimulatorPlaceholder() {
28
+ backgroundColor = .black
29
+
30
+ let label = UILabel()
31
+ label.text = "Unity is not available\non iOS Simulator"
32
+ label.textAlignment = .center
33
+ label.numberOfLines = 0
34
+ label.textColor = UIColor(white: 0.6, alpha: 1)
35
+ label.font = .systemFont(ofSize: 14)
36
+ label.translatesAutoresizingMaskIntoConstraints = false
37
+ addSubview(label)
38
+
39
+ NSLayoutConstraint.activate([
40
+ label.centerXAnchor.constraint(equalTo: centerXAnchor),
41
+ label.centerYAnchor.constraint(equalTo: centerYAnchor),
42
+ label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 16),
43
+ label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
44
+ ])
45
+ }
46
+ #endif
47
+
48
+ // MARK: - Device
49
+
50
+ #if !targetEnvironment(simulator)
51
+ private func setupUnity() {
52
+ let bridge = UnityBridge.shared()
53
+
54
+ if !bridge.isInitialized() {
55
+ bridge.initialize()
56
+ }
57
+
58
+ bridge.onMessage = { [weak self] message in
59
+ DispatchQueue.main.async {
60
+ self?.onUnityMessage([
61
+ "message": message
62
+ ])
63
+ }
64
+ }
65
+
66
+ mountUnityView()
67
+ }
68
+
69
+ private func mountUnityView() {
70
+ guard let unityView = UnityBridge.shared().unityRootView() else {
71
+ NSLog("[ExpoUnity] Unity root view not available yet")
72
+ return
73
+ }
74
+
75
+ if let unityWindow = UnityBridge.shared().unityWindow() {
76
+ if let myWindow = self.window, unityWindow != myWindow {
77
+ unityWindow.isHidden = true
78
+ unityWindow.isUserInteractionEnabled = false
79
+ myWindow.makeKeyAndVisible()
80
+ }
81
+ }
82
+
83
+ unityView.frame = self.bounds
84
+ if unityView.superview != self {
85
+ self.addSubview(unityView)
86
+ }
87
+ }
88
+
89
+ override func layoutSubviews() {
90
+ super.layoutSubviews()
91
+
92
+ if let unityView = UnityBridge.shared().unityRootView(),
93
+ unityView.superview == self {
94
+ unityView.frame = self.bounds
95
+ } else {
96
+ mountUnityView()
97
+ }
98
+ }
99
+
100
+ override func removeFromSuperview() {
101
+ let bridge = UnityBridge.shared()
102
+ bridge.onMessage = nil
103
+
104
+ if autoUnloadOnUnmount && bridge.isInitialized() {
105
+ bridge.unload()
106
+ NSLog("[ExpoUnity] Auto-unloaded (view removed from superview)")
107
+ } else if !autoUnloadOnUnmount && bridge.isInitialized() {
108
+ bridge.pause(true)
109
+ NSLog("[ExpoUnity] Auto-paused on unmount (autoUnloadOnUnmount=false)")
110
+ }
111
+
112
+ super.removeFromSuperview()
113
+ }
114
+ #endif
115
+ }
@@ -0,0 +1,26 @@
1
+ #import <Foundation/Foundation.h>
2
+ #import <UIKit/UIKit.h>
3
+
4
+ NS_ASSUME_NONNULL_BEGIN
5
+
6
+ typedef void (^UnityMessageCallback)(NSString * _Nonnull message);
7
+
8
+ @interface UnityBridge : NSObject
9
+
10
+ @property (nonatomic, copy, nullable) UnityMessageCallback onMessage;
11
+
12
+ + (instancetype)shared;
13
+
14
+ - (BOOL)isInitialized;
15
+ - (void)initialize;
16
+ - (void)sendMessage:(NSString *)gameObject
17
+ methodName:(NSString *)methodName
18
+ message:(NSString *)message;
19
+ - (void)pause:(BOOL)pause;
20
+ - (void)unload;
21
+ - (nullable UIView *)unityRootView;
22
+ - (nullable UIWindow *)unityWindow;
23
+
24
+ @end
25
+
26
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,180 @@
1
+ #import "UnityBridge.h"
2
+
3
+ // ------------------------------------------------------------------
4
+ // UnityBridge — singleton that owns the UnityFramework lifecycle.
5
+ // Called from Swift via @objc interop.
6
+ //
7
+ // On Simulator, all methods are no-ops because Unity as a Library
8
+ // does not support the iOS Simulator target.
9
+ // ------------------------------------------------------------------
10
+
11
+ #if TARGET_OS_SIMULATOR
12
+
13
+ // MARK: - Simulator stubs
14
+
15
+ @implementation UnityBridge
16
+
17
+ static UnityBridge *_shared = nil;
18
+
19
+ + (instancetype)shared {
20
+ static dispatch_once_t onceToken;
21
+ dispatch_once(&onceToken, ^{
22
+ _shared = [[UnityBridge alloc] init];
23
+ });
24
+ return _shared;
25
+ }
26
+
27
+ - (BOOL)isInitialized { return NO; }
28
+ - (void)initialize { NSLog(@"[ExpoUnity] Unity is not available on iOS Simulator"); }
29
+ - (nullable UIView *)unityRootView { return nil; }
30
+ - (nullable UIWindow *)unityWindow { return nil; }
31
+ - (void)sendMessage:(NSString *)gameObject methodName:(NSString *)methodName message:(NSString *)message {}
32
+ - (void)pause:(BOOL)pause {}
33
+ - (void)unload {}
34
+
35
+ @end
36
+
37
+ #else // !TARGET_OS_SIMULATOR
38
+
39
+ // MARK: - Device implementation
40
+
41
+ #import <UnityFramework/UnityFramework.h>
42
+ #import <UnityFramework/NativeCallProxy.h>
43
+
44
+ #ifdef DEBUG
45
+ #include <mach-o/ldsyms.h>
46
+ #endif
47
+
48
+ @interface UnityBridge () <NativeCallsProtocol, UnityFrameworkListener>
49
+
50
+ @property (nonatomic, strong, nullable) UnityFramework *ufwInternal;
51
+
52
+ @end
53
+
54
+ @implementation UnityBridge
55
+
56
+ static UnityBridge *_shared = nil;
57
+
58
+ + (instancetype)shared {
59
+ static dispatch_once_t onceToken;
60
+ dispatch_once(&onceToken, ^{
61
+ _shared = [[UnityBridge alloc] init];
62
+ });
63
+ return _shared;
64
+ }
65
+
66
+ - (BOOL)isInitialized {
67
+ return self.ufwInternal != nil && [self.ufwInternal appController] != nil;
68
+ }
69
+
70
+ - (void)initialize {
71
+ if ([self isInitialized]) return;
72
+
73
+ NSString *bundlePath = [[NSBundle mainBundle] bundlePath];
74
+ bundlePath = [bundlePath stringByAppendingString:@"/Frameworks/UnityFramework.framework"];
75
+
76
+ NSBundle *bundle = [NSBundle bundleWithPath:bundlePath];
77
+ if (![bundle isLoaded]) [bundle load];
78
+
79
+ UnityFramework *ufw = [bundle.principalClass getInstance];
80
+ if (![ufw appController]) {
81
+ #ifdef DEBUG
82
+ [ufw setExecuteHeader:&_mh_dylib_header];
83
+ #else
84
+ [ufw setExecuteHeader:&_mh_execute_header];
85
+ #endif
86
+ }
87
+
88
+ [ufw setDataBundleId:[bundle.bundleIdentifier cStringUsingEncoding:NSUTF8StringEncoding]];
89
+
90
+ // Boot Unity
91
+ NSArray *args = [[NSProcessInfo processInfo] arguments];
92
+ int argc = (int)args.count;
93
+ char **argv = (char **)malloc((argc + 1) * sizeof(char *));
94
+ for (int i = 0; i < argc; i++) {
95
+ argv[i] = strdup([args[i] UTF8String]);
96
+ }
97
+ argv[argc] = NULL;
98
+
99
+ [ufw runEmbeddedWithArgc:1 argv:argv appLaunchOpts:nil];
100
+ [ufw appController].quitHandler = ^{ NSLog(@"[ExpoUnity] Unity quit handler called"); };
101
+
102
+ // Register for callbacks
103
+ [ufw registerFrameworkListener:self];
104
+ [NSClassFromString(@"FrameworkLibAPI") registerAPIforNativeCalls:self];
105
+
106
+ self.ufwInternal = ufw;
107
+
108
+ // Hide Unity's window — we embed its rootView in our own view
109
+ UIWindow *unityWindow = [ufw appController].window;
110
+ if (unityWindow) {
111
+ unityWindow.hidden = YES;
112
+ unityWindow.userInteractionEnabled = NO;
113
+ }
114
+
115
+ NSLog(@"[ExpoUnity] Unity initialized");
116
+ }
117
+
118
+ - (nullable UIView *)unityRootView {
119
+ if (![self isInitialized]) return nil;
120
+ return [self.ufwInternal appController].rootView;
121
+ }
122
+
123
+ - (nullable UIWindow *)unityWindow {
124
+ if (![self isInitialized]) return nil;
125
+ return [self.ufwInternal appController].window;
126
+ }
127
+
128
+ - (void)sendMessage:(NSString *)gameObject
129
+ methodName:(NSString *)methodName
130
+ message:(NSString *)message {
131
+ if (![self isInitialized]) return;
132
+ dispatch_async(dispatch_get_main_queue(), ^{
133
+ [self.ufwInternal sendMessageToGOWithName:[gameObject UTF8String]
134
+ functionName:[methodName UTF8String]
135
+ message:[message UTF8String]];
136
+ });
137
+ }
138
+
139
+ - (void)pause:(BOOL)pause {
140
+ if (![self isInitialized]) return;
141
+ dispatch_async(dispatch_get_main_queue(), ^{
142
+ [self.ufwInternal pause:pause];
143
+ });
144
+ }
145
+
146
+ - (void)unload {
147
+ NSLog(@"[ExpoUnity] unload called, isInitialized=%d", [self isInitialized]);
148
+ if (![self isInitialized]) return;
149
+ UIWindow *mainWindow = [[[UIApplication sharedApplication] delegate] window];
150
+ if (mainWindow) [mainWindow makeKeyAndVisible];
151
+ [self.ufwInternal unloadApplication];
152
+ NSLog(@"[ExpoUnity] unloadApplication called");
153
+ }
154
+
155
+ // MARK: - NativeCallsProtocol (Unity → RN)
156
+
157
+ - (void)sendMessageToMobileApp:(NSString *)message {
158
+ if (self.onMessage) {
159
+ self.onMessage(message);
160
+ }
161
+ }
162
+
163
+ // MARK: - UnityFrameworkListener
164
+
165
+ - (void)unityDidUnload:(NSNotification *)notification {
166
+ NSLog(@"[ExpoUnity] unityDidUnload notification received");
167
+ [self.ufwInternal unregisterFrameworkListener:self];
168
+ self.ufwInternal = nil;
169
+ NSLog(@"[ExpoUnity] ufwInternal set to nil, ready for re-initialize");
170
+ }
171
+
172
+ - (void)unityDidQuit:(NSNotification *)notification {
173
+ NSLog(@"[ExpoUnity] Unity did quit");
174
+ [self.ufwInternal unregisterFrameworkListener:self];
175
+ self.ufwInternal = nil;
176
+ }
177
+
178
+ @end
179
+
180
+ #endif // !TARGET_OS_SIMULATOR
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@dolami-inc/react-native-expo-unity",
3
+ "version": "0.1.1",
4
+ "description": "Unity as a Library (UaaL) bridge for React Native / Expo",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "plugin": "./app.plugin.js",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "clean": "rm -rf build"
11
+ },
12
+ "files": [
13
+ "src/",
14
+ "ios/",
15
+ "plugin/",
16
+ "docs/",
17
+ "app.plugin.js",
18
+ "expo-module.config.json"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/fahreziadh/react-native-expo-unity.git"
23
+ },
24
+ "keywords": [
25
+ "expo",
26
+ "react-native",
27
+ "unity",
28
+ "unity-as-a-library",
29
+ "uaal",
30
+ "bridge",
31
+ "ar"
32
+ ],
33
+ "author": "Fahrezi <fahrezi@dolami.is>",
34
+ "license": "MIT",
35
+ "bugs": {
36
+ "url": "https://github.com/fahreziadh/react-native-expo-unity/issues"
37
+ },
38
+ "homepage": "https://github.com/fahreziadh/react-native-expo-unity#readme",
39
+ "peerDependencies": {
40
+ "expo": ">=54.0.0",
41
+ "react": "*",
42
+ "react-native": "*"
43
+ },
44
+ "devDependencies": {
45
+ "@types/react": "~19.1.10",
46
+ "expo-module-scripts": "~4.0.0",
47
+ "typescript": "~5.8.0"
48
+ }
49
+ }
@@ -0,0 +1,15 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ @protocol NativeCallsProtocol
4
+ @required
5
+
6
+ - (void) sendMessageToMobileApp:(NSString*)message;
7
+
8
+ @end
9
+
10
+ __attribute__ ((visibility("default")))
11
+ @interface FrameworkLibAPI : NSObject
12
+
13
+ +(void) registerAPIforNativeCalls:(id<NativeCallsProtocol>) aApi;
14
+
15
+ @end
@@ -0,0 +1,20 @@
1
+ #import <Foundation/Foundation.h>
2
+ #import "NativeCallProxy.h"
3
+
4
+ @implementation FrameworkLibAPI
5
+
6
+ id<NativeCallsProtocol> api = NULL;
7
+ +(void) registerAPIforNativeCalls:(id<NativeCallsProtocol>) aApi
8
+ {
9
+ api = aApi;
10
+ }
11
+
12
+ @end
13
+
14
+ extern "C"
15
+ {
16
+ void sendMessageToMobileApp(const char* message)
17
+ {
18
+ return [api sendMessageToMobileApp:[NSString stringWithUTF8String:message]];
19
+ }
20
+ }
@@ -0,0 +1,43 @@
1
+ import { requireNativeModule } from "expo";
2
+
3
+ const ExpoUnityModule = requireNativeModule("ExpoUnity");
4
+
5
+ /**
6
+ * Send a message to a Unity GameObject.
7
+ */
8
+ export function postMessage(
9
+ gameObject: string,
10
+ methodName: string,
11
+ message: string
12
+ ): void {
13
+ ExpoUnityModule.postMessage(gameObject, methodName, message);
14
+ }
15
+
16
+ /**
17
+ * Pause Unity rendering and execution.
18
+ */
19
+ export function pauseUnity(): void {
20
+ ExpoUnityModule.pauseUnity(true);
21
+ }
22
+
23
+ /**
24
+ * Resume Unity rendering and execution.
25
+ */
26
+ export function resumeUnity(): void {
27
+ ExpoUnityModule.pauseUnity(false);
28
+ }
29
+
30
+ /**
31
+ * Unload Unity and free memory.
32
+ * After this, Unity must be re-initialized (remount the view).
33
+ */
34
+ export function unloadUnity(): void {
35
+ ExpoUnityModule.unloadUnity();
36
+ }
37
+
38
+ /**
39
+ * Check if Unity is currently initialized.
40
+ */
41
+ export function isInitialized(): boolean {
42
+ return ExpoUnityModule.isInitialized();
43
+ }
@@ -0,0 +1,56 @@
1
+ import { requireNativeView } from "expo";
2
+ import { forwardRef, useImperativeHandle, useCallback, useRef } from "react";
3
+ import type { ViewProps } from "react-native";
4
+ import type { UnityMessageEvent } from "./types";
5
+ import { postMessage, pauseUnity, resumeUnity, unloadUnity } from "./ExpoUnity";
6
+
7
+ // Native view from Expo Modules
8
+ const NativeUnityView = requireNativeView("ExpoUnity");
9
+
10
+ export interface UnityViewProps extends ViewProps {
11
+ onUnityMessage?: (event: UnityMessageEvent) => void;
12
+ /**
13
+ * If true (default), Unity will automatically unload when the view unmounts.
14
+ * If false, Unity will only pause on unmount — state is preserved for faster re-mount.
15
+ * Use false when users frequently navigate back and forth to the Unity screen.
16
+ */
17
+ autoUnloadOnUnmount?: boolean;
18
+ }
19
+
20
+ export interface UnityViewRef {
21
+ postMessage: (gameObject: string, methodName: string, message: string) => void;
22
+ pauseUnity: () => void;
23
+ resumeUnity: () => void;
24
+ unloadUnity: () => void;
25
+ }
26
+
27
+ export const UnityView = forwardRef<UnityViewRef, UnityViewProps>(
28
+ ({ onUnityMessage, autoUnloadOnUnmount = true, ...props }, ref) => {
29
+ const nativeRef = useRef(null);
30
+
31
+ useImperativeHandle(ref, () => ({
32
+ postMessage,
33
+ pauseUnity,
34
+ resumeUnity,
35
+ unloadUnity,
36
+ }));
37
+
38
+ const handleUnityMessage = useCallback(
39
+ (event: { nativeEvent: UnityMessageEvent }) => {
40
+ onUnityMessage?.(event.nativeEvent);
41
+ },
42
+ [onUnityMessage]
43
+ );
44
+
45
+ return (
46
+ <NativeUnityView
47
+ ref={nativeRef}
48
+ onUnityMessage={handleUnityMessage}
49
+ autoUnloadOnUnmount={autoUnloadOnUnmount}
50
+ {...props}
51
+ />
52
+ );
53
+ }
54
+ );
55
+
56
+ UnityView.displayName = "UnityView";
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { UnityView } from "./UnityView";
2
+ export type { UnityViewRef, UnityViewProps } from "./UnityView";
3
+ export { postMessage, pauseUnity, resumeUnity, unloadUnity, isInitialized } from "./ExpoUnity";
4
+ export type { UnityMessageEvent } from "./types";
package/src/types.ts ADDED
@@ -0,0 +1,3 @@
1
+ export interface UnityMessageEvent {
2
+ message: string;
3
+ }