Create Game Apps Using Godot Engine in Expo with react-native-godot

Software Engineer
Introduction
With the release of @borndotcom/react-native-godot, React Native developers can now embed a full Godot Engine runtime directly inside their apps. This unlocks a new class of hybrid apps: React Native UI on top, Godot rendering underneath.
For the first time, you can:
Use React Native for UI, navigation, and app features
Use Godot for rendering, physics, input, or full game scenes
Ship both platforms (iOS & Android) from a single project.
In this guide, I’ll show you how to set up @borndotcom/react-native-godot inside an Expo app and run a Godot 2D game with React Native UI controls layered on top. We’ll walk through:
how to set up Godot within your Expo project
how to add your Godot assets and configure them to bundle correctly for iOS and Android using Expo's config plugin system
how to render the GodotView component in your React Native code and wire up controls that communicate between the two layers
By the time we're finished, you'll have a fully functional Expo app running Godot game on both iOS and Android.
Setting Up Godot in Your Expo Project
Create an Expo Project
If you don’t already have an Expo project, you can create one using the Expo CLI. This guide uses a simple Default template, but you can choose any.
pnpm create expo-app --template
? Choose a template: › - Use arrow-keys. Return to submit.
❯ Default - includes tools recommended for most app developers
Blank
Blank (TypeScript)
Navigation (TypeScript)
Blank (Bare)
Once created, navigate into the project:
cd godotdemo
Install Required Dependencies
Along with @borndotcom/react-native-godot we’ll install a few additional libraries used in this guide:
expo-build-properties – to configure Android-specific settings.
expo-file-system – for handling file system paths from React Native.
@expo/vector-icons – optional, for building UI controls.
npx expo install expo-build-properties expo-file-system @expo/vector-icons
pnpm add -D expo-module-scripts
pnpm install @borndotcom/react-native-godot
Download the prebuilt LibGodot packages
@borndotcom/react-native-godot depends on LibGodot packages. These packages are not distributed on npm and must be downloaded separately.
Setup the download script
Open your project’s package.json and add a script like:
{
"scripts": {
"download-prebuilt": "download-prebuilt"
}
}
Then run:
pnpm run download-prebuilt
This downloads the correct LibGodot binaries for both iOS and Android.
Optional: Automate it with postinstall
To make future installs easier, you can automatically download LibGodot every time someone installs dependencies. Add this to your package.json:
{
"scripts": {
"download-prebuilt": "download-prebuilt",
"postinstall": "pnpm run download-prebuilt"
}
}
Set orientation
If your game is designed for landscape mode set the app orientation in app.json:
{
"expo": {
"orientation": "landscape"
}
}
Configure Android Build Properties
Before generating native folders, make sure your Android settings are correct. Add this to your app.json / app.config.js:
{
"expo": {
"plugins": [
[
"expo-build-properties",
{
"android": {
"minSdkVersion": 29,
"extraMavenRepos": [
"../../node_modules/@borndotcom/react-native-godot/android/libs/libgodot-android/4.5.1.migeran.2"
]
}
}
]
]
}
}
This configuration:
Sets the required minSdkVersion = 29
Registers the LibGodot local Maven repo, so Android builds can resolve engine binaries.
Generating the iOS and Android Folders
If this is a brand-new Expo project, you may not see ios or android folders yet. To create these native folders (so config plugins can attach and assets can be bundled correctly), run:
npx expo prebuild
Now that the project is fully set up, we can add the Godot game assets that will be bundled into the iOS and Android builds.
Add Your Godot Game Assets
Now that we have the basic setup in place, let's talk about getting your actual Godot game content into your Expo project. iOS and Android handle game assets differently and understanding why will help you make sense of the setup.
When you export a game from Godot you have two options for how to package it as a single pack file (with a .pck extension) or as a folder containing all your game's individual files. Both approaches work, but each platform has different performance characteristics that make one approach better than the other.
To keep this guide focused on integration rather than building a Godot game we’ll use pre-exported Godot assets. You can replace them with your own.
You can download the exact assets used in this guide from:
iOS Asset Setup
For your iOS build you'll want to place your exported Godot game as a pack file. Create a godot folder inside your project’s assets directory then add your file:
assets/godot/main.pck
iOS handles pack files efficiently so using a pack file is actually the better choice. It's cleaner easier to manage, and doesn't sacrifice any performance.
Android Asset Setup
As per the @borndotcom/react-native-godot documentation:
Because of this, for Android we’ll use the exported folder of files from Godot instead of a single .pck file.
Create a godot-files folder inside the godot directory we created in the previous setup, then place your exported files folder inside it
assets/godot/godot-files/main/
This is where your Android Godot game export should live.
Final Folder Structure
At this point, your project should have the Godot assets placed correctly for both iOS and Android. Here’s what the expected structure looks like:
assets/
godot/
main.pck # iOS build uses the .pck file
godot-files/ # Android build uses unpacked files
main/
(your exported Godot files here)
With the assets now in place, the next step is to configure the Expo config plugins so these files are included correctly in your iOS and Android builds.
Expo Config Plugin — Copy Godot Assets to Native Folders
At this point, you have your Godot assets in your Expo project assets directory, but there's an important step that needs to happen those assets need to copy into the native iOS and Android project folders when your app prebuilds. Expo doesn't automatically move files from your project assets into the native folders which is where config plugins come in.
A config plugin allows you to modify the native iOS and Android projects that Expo generates during the prebuild process in Continuous Native Generation (CNG) projects.
For Android, this is relatively straightforward. The plugin copies your godot-files folder into the Android main package assets directory at android/app/src/main/assets/. Once the files are there, Android knows how to bundle them with your app and make them accessible at runtime.
For iOS however, there's a critical detail you need to understand. The plugin copies your main.pck file into the root of the iOS project, but that's only half the story. In iOS development, simply having a file present in your project folder is not enough. The file also needs to be added to the "main app target". You can think of the target as a list that tells Xcode which files should actually be included when it compiles and packages your app. If a file is not in the target it won't be bundled with your app even if it's present there in the project folder.
Creating the Folder Structure
First, create a folder named plugin in your project root. Inside that folder, create a src directory this is where all of TypeScript plugin files will live.
Structure should look like this:
plugin/
src/
index.ts
withGodotFiles.ts // Android
withPckFile.ts // iOS
tsconfig.json
Add the Plugin Code
With the folder structure in place the next step is to create the three plugin files and add the code that will copy Godot assets into the native projects during Expo’s prebuild step.
Inside plugin/src/, create the following files:
index.ts
This is the entry point for the plugin. It runs the Android and iOS plugin together during prebuild.
import { ConfigPlugin } from "expo/config-plugins";
import withGodotFiles from "./withGodotFiles";
import withPckFile from "./withPckFile";
const withPlugin: ConfigPlugin = (config) => {
// Copy Godot files to Android assets
config = withGodotFiles(config);
// Copy main.pck to iOS project
return withPckFile(config);
};
export default withPlugin;
withGodotFiles.ts (Android)
This plugin copies Godot exported files into the Android app’s native assets folder:
import { ConfigPlugin, withDangerousMod } from "expo/config-plugins";
import * as fs from "fs";
import * as path from "path";
/**
* Recursively copy a directory
*/
function copyDirectory(src: string, dest: string): void {
// Create destination directory if it doesn't exist
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
// Read all files and directories from source
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
// Recursively copy subdirectories
copyDirectory(srcPath, destPath);
} else {
// Copy files
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Expo config plugin to copy godot-files/main folder to Android assets
* @param config - Expo config
*/
const withGodotFiles: ConfigPlugin = (config) => {
return withDangerousMod(config, [
"android",
async (config) => {
const projectRoot = config.modRequest.projectRoot;
// Source path: assets/godot/godot-files/main
const sourcePath = path.join(
projectRoot,
"assets",
"godot",
"godot-files",
"main"
);
// Destination path: android/app/src/main/assets/main
const destPath = path.join(
projectRoot,
"android",
"app",
"src",
"main",
"assets",
"main"
);
// Check if source directory exists
if (!fs.existsSync(sourcePath)) {
console.warn(`Godot files not found at ${sourcePath}`);
return config;
}
// Ensure the assets directory exists
const assetsDir = path.join(
projectRoot,
"android",
"app",
"src",
"main",
"assets"
);
if (!fs.existsSync(assetsDir)) {
fs.mkdirSync(assetsDir, { recursive: true });
}
// Copy the directory
try {
copyDirectory(sourcePath, destPath);
console.log(`Copied Godot files from ${sourcePath} to ${destPath}`);
} catch (error) {
console.error(`Error copying Godot files: ${error}`);
}
return config;
},
]);
};
export default withGodotFiles;
withPckFile.ts (iOS)
This plugin copies main.pck file into the iOS project and adds it to the Xcode main app target which is required for it to be bundled in the final app.
import { ConfigPlugin, withXcodeProject } from "expo/config-plugins";
import * as fs from "fs";
import * as path from "path";
/**
* Expo config plugin to copy main.pck file from assets/godot to iOS project
* @param config - Expo config
*/
const withPckFiles: ConfigPlugin = (config) => {
return withXcodeProject(config, async (config) => {
const projectRoot = config.modRequest.projectRoot;
const project = config.modResults;
// Source path: assets/godot/main.pck
const sourcePath = path.join(projectRoot, "assets", "godot", "main.pck");
// Destination path: ios/main.pck (at the root of ios folder)
const destPath = path.join(projectRoot, "ios", "main.pck");
// Check if source file exists
if (!fs.existsSync(sourcePath)) {
console.warn("main.pck not found at assets/godot/main.pck");
return config;
}
// Copy the file
fs.copyFileSync(sourcePath, destPath);
console.log("Copied main.pck to iOS project");
// Check if file already exists in the project
const existingFile = project.hasFile("main.pck");
if (existingFile) {
console.log("main.pck already exists in Xcode project");
return config;
}
// Get the main group (root group of the project)
const firstProject = project.getFirstProject();
const mainGroupKey = firstProject.firstProject.mainGroup;
if (!mainGroupKey) {
console.error("Could not find main group");
return config;
}
console.log("Main group key:", mainGroupKey);
// Add the file reference with proper encoding
const file = project.addFile("main.pck", mainGroupKey, {
lastKnownFileType: "file",
defaultEncoding: 4,
});
if (!file) {
console.log("Could not add file - it may already exist");
return config;
}
console.log("Added file reference:", file.fileRef);
// Get target UUID
const targetUuid = project.getFirstTarget().uuid;
console.log("Target UUID:", targetUuid);
// Generate UUID for build file
const buildFileUuid = project.generateUuid();
// Manually add to PBXBuildFile section with target
project.addToPbxBuildFileSection({
uuid: buildFileUuid,
isa: "PBXBuildFile",
fileRef: file.fileRef,
basename: "main.pck",
group: "Resources",
});
console.log("Added to PBXBuildFile section");
// Add to Resources build phase with target
project.addToPbxResourcesBuildPhase({
uuid: buildFileUuid,
basename: "main.pck",
group: "Resources",
target: targetUuid,
});
console.log("Added to Resources build phase with target");
return config;
});
};
export default withPckFiles;
plugin/tsconfig.json
{
"extends": "expo-module-scripts/tsconfig.plugin",
"compilerOptions": {
"outDir": "build",
"rootDir": "src"
},
"include": ["./src"],
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
}
Build the Plugin
Before Expo can use config plugin, you need to compile it from TypeScript to JavaScript. To do that add a script to your package.json:
{
"scripts": {
"build:plugin": "tsc --build plugin/tsconfig.json"
}
}
Now run:
pnpm build:plugin
This will generate the compiled JavaScript files inside plugin/build/ which Expo will load during the prebuild process.
Configure the Plugin
Create an app.plugin.js file in the root directory and add below code:
module.exports = require("./plugin/build");
Open app.config.js (or app.json) and plugin under the plugins field:
{
"expo": {
"plugins": [
"./app.plugin.js",
...
..
]
}
}
Run Prebuild
With the plugin setup, the final step is to run Expo’s prebuild process. This is where your plugin actually executes and copies the Godot assets into the native project folders.
npx expo prebuild
After this completes, you should see main.pck copied into the iOS project and unpacked Godot export copied into android/app/src/main/assets/main/
Android

iOS

That’s it for the native side. Config plugin is in place and your Godot assets will always be in the right place for both iOS and Android no manual steps needed.
Next, we’ll look at how to render your Godot scene inside React Native and interact with it.
Bringing Your Godot Game to Life in React Native
Now comes the exciting part actually getting your Godot game running inside your React Native app. Before we can render anything on screen, we need to initialize the Godot engine itself.
Create the Godot instance
The initialization process looks slightly different on iOS and Android and that's because of how each platform handles the game files we set up earlier.
On Android remember how we added all those Godot export files into the /main directory inside android/app/src/main/assets/ Well Android will load the game directly from that unpacked directory structure.
On iOS it loads from that single .pck file we copied into the app bundle earlier. We need to tell the Godot engine the exact path to that file which is where the Expo FileSystem module comes in handy. It helps us construct the correct file path so the engine knows where to find the packed game data.
Let's write the code that handles this initialization.
We'll import the Expo FileSystem module along with our RTNGodot module, and then we'll call the initialization function with the appropriate path based on which platform we're running on. Here's what that looks like:
import { Platform } from "react-native";
import { RTNGodot, runOnGodotThread } from "@borndotcom/react-native-godot";
import * as FileSystem from "expo-file-system/legacy";
function initGodot() {
// We need to run this code on Godot's own thread, not the main JavaScript thread
// That's what runOnGodotThread does for us
runOnGodotThread(() => {
"worklet"; // This tells the system: this function needs to run on a different thread
if (Platform.OS === "android") {
// Android: Point to the unpacked game files in the assets folder
RTNGodot.createInstance([
"--verbose", // Enable detailed logging
"--path",
"/main", // Load from /main directory
"--rendering-driver",
"opengl3",
"--rendering-method",
"gl_compatibility",
"--display-driver",
"embedded", // required to embed Godot into the React Native application.
]);
} else {
// iOS: Point directly to the main.pck file in the app bundle
RTNGodot.createInstance([
"--verbose",
"--main-pack",
FileSystem.bundleDirectory + "main.pck", // Full path to .pck file
"--rendering-driver",
"opengl3",
"--rendering-method",
"gl_compatibility",
"--display-driver",
"embedded",
]);
}
});
}
Notice we're wrapping our initialization code inside runOnGodotThread. Here's why that matters. In React Native, JavaScript code runs on its own thread, completely separate from the native UI thread. This is actually a good thing because it means JavaScript logic doesn't freeze the app's interface. Following that same pattern the Godot engine also runs on its own dedicated thread keeping it independent from both the native UI and your JavaScript code.
JavaScript by itself is single-threaded which means it can only do one thing at a time on one thread. So how do we get our JavaScript code to talk to the Godot thread? That's where the concept of "worklets" comes in. A worklet is just a JavaScript function that you mark with the 'worklet' keyword at the top. This special keyword tells the system to package up that function and all the code it depends on into a self-contained bundle that can be executed on a different thread.
When we call runOnGodotThread and pass it a worklet function it saying take this chunk of JavaScript and run it over there on Godot's thread, not here on the main JavaScript thread. This is the safest way to interact with the Godot engine because it keeps everything in the right place. The Godot engine expects certain operations to happen on its own thread and by using worklets we're respecting that expectation.
The initialization itself is pretty straightforward. We're passing an array of command-line arguments to RTNGodot.createInstance just like you would if you were launching Godot from a terminal.
Add the Godot View
To show your Godot scene in the app we just render the RTNGodotView component inside our layout. This is the view where your Godot game will appear.
import {
RTNGodotView,
} from "@borndotcom/react-native-godot";
import { View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
}}
>
<RTNGodotView style={{ flex: 1 }} />
</View>
);
}
This puts your Godot game on the screen as a full-screen view. Because it behaves like any other React Native component you can easily layer buttons or other UI elements on top of it.
Call the Initialization When the App Loads
Now that we have our initGodot() function and the RTNGodotView on the screen we just need to trigger the initialization when the component mounts. The easiest way to do that is with a useEffect.
export default function Index() {
useEffect(() => {
initGodot();
return () => {};
}, []);
return (
<View
style={{
flex: 1,
}}
>
<RTNGodotView style={{ flex: 1 }} />
</View>
);
}
At this point, we’ve initialized Godot and added the RTNGodotView, so the game should be able to load. Go ahead and run the app to make sure everything is working.
pnpm run ios
# or
pnpm run android
Running on iOS

Running on Android

Control the Game
With the game now rendering inside your React Native app, the next step is to actually play it. Because the Godot scene is running inside RTNGodotView we can place any React Native UI on top like buttons, joysticks whatever we want.
For this example, we’ll add a simple three-button control layout:
Left (move left)
Right (move right)
Jump
Build the Control UI
Below is the UI setup for the three on-screen buttons (left, right, and jump), positioned on top of the Godot view:
import { Ionicons } from "@expo/vector-icons";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import {
RTNGodotView
} from "@borndotcom/react-native-godot";
export default function Index() {
useEffect(() => {
initGodot();
return () => {};
}, []);
return (
<View style={styles.container}>
<RTNGodotView style={styles.gameView} />
{/* Left side controls - Direction buttons */}
<View style={styles.leftControls}>
<TouchableOpacity
style={styles.button}
onPressIn={() => {}}
onPressOut={() => {})}
activeOpacity={0.7}
>
<Ionicons name="chevron-back" size={32} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={styles.button}
onPressIn={() => {}}
onPressOut={() => {}}
activeOpacity={0.7}
>
<Ionicons name="chevron-forward" size={32} color="white" />
</TouchableOpacity>
</View>
{/* Right side controls - Jump button */}
<View style={styles.rightControls}>
<TouchableOpacity
style={[styles.button, styles.jumpButton]}
onPressIn={() => {}}
onPressOut={() => {}}
activeOpacity={0.7}
>
<Ionicons name="arrow-up" size={36} color="white" />
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gameView: {
flex: 1,
},
leftControls: {
position: "absolute",
bottom: 40,
left: 30,
flexDirection: "row",
gap: 20,
},
rightControls: {
position: "absolute",
bottom: 40,
right: 30,
},
button: {
width: 70,
height: 70,
borderRadius: 35,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "center",
alignItems: "center",
borderWidth: 3,
borderColor: "rgba(255, 255, 255, 0.3)",
},
jumpButton: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: "rgba(220, 38, 38, 0.7)",
borderColor: "rgba(255, 255, 255, 0.4)",
},
});
Here’s the layout we’ll use for the on-screen controls:

Handle the Button Actions
Now that we've got our buttons on screen we need to make them actually do something. This is where things get interesting because we're building a bridge between two different worlds React Native UI and the Godot game engine running underneath. Let's break down how this communication happens.
How Godot Handles Input
First, let's look at how your Godot game is expecting to receive input. In your GDScript code you've probably got something like this for handling movement:
var direction := Input.get_axis("ui_left", "ui_right")
This single line is doing something clever. The Input.get_axis() function looks at two actions in this case "ui_left" and "ui_right" and returns a value between -1 and 1. If the left action is pressed you get -1. If the right action is pressed you get 1. If neither is pressed you get 0. This makes it really easy to handle movement in your game logic.
For jumping you're probably checking if an action was just pressed like this:
if Input.is_action_just_pressed("ui_accept") and is_on_floor():
velocity.y = JUMP_VELOCITY
This checks if the "ui_accept" action was pressed this frame and if the character is standing on the ground. If both conditions are true, the character jumps.
These actions come straight from the player.gd script in the Godot project. That’s where the character’s movement and jump input are defined.
Building the Bridge from React Native
So how do we trigger these Godot input actions from our React Native buttons? We need two helper functions: one to tell Godot hey, this button was just pressed and another to say okay, the button was released. Here's what those look like:
function pressAction(action: string) {
runOnGodotThread(() => {
"worklet";
try {
const Godot = RTNGodot.API();
const Input = Godot.Input;
Input.action_press(action);
} catch (error) {
console.error("Error pressing action:", error);
}
});
}
function releaseAction(action: string) {
runOnGodotThread(() => {
"worklet";
try {
const Godot = RTNGodot.API();
const Input = Godot.Input;
Input.action_release(action);
} catch (error) {
console.error("Error releasing action:", error);
}
});
}
Both functions follow the same pattern we saw earlier with initialization they use runOnGodotThread to make sure the code runs on Godot's dedicated thread, not the main JavaScript thread.
Inside each function, we're accessing the Godot API and specifically grabbing the Input singleton, which is Godot's global input manager. When we call Input.action_press(action) we're telling Godot simulate pressing this action right now. When we call Input.action_release(action) we're saying simulate releasing it.
Wiring Up the Buttons
When you create your React Native buttons you hook up these functions to the touch events:
<TouchableOpacity
onPressIn={() => pressAction("ui_left")}
onPressOut={() => releaseAction("ui_left")}
>
<Ionicons name="chevron-back" size={32} color="white" />
</TouchableOpacity>
When someone touches the left arrow onPressIn fires and calls pressAction("ui_left"). This tells Godot that the left action is now pressed. In GDScript Input.get_axis("ui_left", "ui_right") immediately starts returning -1 and your character begins moving left. When they lift their finger onPressOut fires calling releaseAction("ui_left") and the movement stops.
The same pattern works for the right button with "ui_right" and the jump button with "ui_accept". The beauty of this approach is that from Godot's perspective it doesn't matter whether the input is coming from a physical keyboard or from your React Native buttons it all looks the same to the Input singleton.
At this point you should be able to move left, move right, and jump directly from your RN UI while the game runs inside the RTNGodotView.

Managing the Godot Instance (Stop, Pause, Resume)
Before wrapping up it’s worth mentioning that you’re not locked into a single running Godot instance. The engine gives you full control over its lifecycle which is useful if you ever want to show a menu screen, change levels, or switch to a different Godot entirely.
Stop the Godot instance
If you want to shut the engine down completely, you can destroy the active instance:
function destroyGodot() {
runOnGodotThread(() => {
"worklet";
RTNGodot.destroyInstance();
});
}
Stopping the instance fully unloads the Godot runtime. You can later start a new instance even with a different Godot project by calling createInstance() again.
Pause the instance
If you just want to temporarily stop the game (for example when opening a fullscreen game settings menu) you can pause execution:
RTNGodot.pause();
This freezes the running scene without destroying it.
Resume the instance
And when you’re ready to continue:
RTNGodot.resume();
This simply continues the gameplay where it left off.
Wrapping Up
And that’s it you now have a full Godot game running inside an Expo app, complete with platform-specific asset handling, a working Expo config plugin, and real-time controls built using plain React Native UI.
If you’d like to explore the complete setup including the config plugin, Godot assets, and the React Native controls I’ve shared the full demo project on GitHub.



