Como embutir Godot em um app React Native e medir o custo real
17 min readEscrito por: Jonathan Reis em
O React Native continuou responsável pela navegação e pelas configurações; o Godot ficou restrito à cena de gameplay. A POC funcionou no Android, mas a conclusão só ficou confiável depois de corrigir artefatos stale, instrumentar a ponte e substituir milhares de ColorRect por desenho batched.
Atualizado:

A pergunta que todo dev de jogo indie encontra
Você já tem um app React Native. Ele tem navegação, providers de tema, i18n, persistência SQLite, integração de anúncios, crash reporting e tela de configurações. Você não quer jogar isso fora para usar Unity ou Unreal, que te dão uma engine de jogo mas te obrigam a reconstruir a casca do app do zero.
Mas o React Native não consegue rodar um game loop de verdade. O reconciler te combate a cada frame. O Skia ajuda com desenho 2D mas não é uma engine — sem física, sem pathfinding, sem ECS, sem engine de áudio. Você tentou. Não foi suficiente para nada além de um tabuleiro de Sudoku.
Então a pergunta é: dá para manter a casca React Native e embutir uma engine de jogo de verdade só para a tela de gameplay?
Por que Godot, não Phaser ou Filament
Considerei três opções antes de fechar com Godot.
Phaser dentro de um WebView é o caminho mais fácil. Você hospeda uma página Canvas/WebGL com Phaser dentro do react-native-webview e sobrepõe componentes nativos do React Native por cima. Funciona para jogos casuais 2D. Mas o estado do jogo mora dentro do WebView, e cada dado que precisa chegar no lado React Native (pontuação, inventário, conquistas) cruza a fronteira do postMessage. Para um puzzle tudo bem. Para um roguelike com estado denso, vira um problema de sincronização.
Filament (renderer PBR do Google) é uma engine de renderização, não uma engine de jogo. Te dá renderização 3D mas sem física, sem áudio, sem gerenciamento de cena, sem scripting. Fiz uma POC com react-native-filament e era exagero para 2D e insuficiente para um jogo completo.
Godot é uma engine completa — física, áudio, scene tree no estilo ECS, pathfinding, partículas, GDScript, editor visual. Desde o Godot 4.x, o export Android suporta o modo “Godot as a Library”: em vez de exportar um APK, você exporta um .aar (Android Archive) que contém o runtime completo da engine. Você embute esse runtime dentro qualquer app Android, incluindo um construído com React Native.
A mesma abordagem funciona no iOS com export de framework. Esta POC cobre só Android, mas a arquitetura é portável.
A arquitetura
A ideia é simples na teoria:
App React Native
├── Navegação, tabs, settings, i18n, ads, crash reporting
├── Providers de tema, persistência SQLite
└── Tela de jogo
├── GodotViewHost (componente React Native)
│ └── GodotView (ViewManager nativo)
│ └── Runtime do Godot (do .aar)
└── HUD overlay (botões React Native, contador de FPS)
O React Native é dono de tudo exceto o gameplay. A tela de jogo hospeda uma View nativa que roda a engine Godot. Botões React Native (pausar, resetar, adicionar entidades) enviam comandos para o lado nativo. O lado nativo envia eventos de volta (FPS, contagem de entidades, mudanças de estado do jogo).
O que construí
O app da POC vive dentro de um monorepo pnpm existente com packages compartilhados (@apps/theme, @apps/i18n, @apps/db, @apps/ads, @apps/ui). O app reusa a árvore completa de providers do app de Sudoku: SettingsProvider → SoundProvider → VibrationProvider → AnalyticsProvider → StoreProvider → AdsProvider → ToastProvider → ModalProvider → AppNavigator.
A navegação usa material-top-tabs (Home, Shop, Profile) — o mesmo padrão do app de Sudoku em produção. Settings persiste em SQLite. A sequência de bootstrap, inicialização de crash reporting e logging de observabilidade são idênticos ao app publicado.
A tela de jogo renderiza uma View nativa — um SimpleViewManager registrado como GodotView — que roda um stress test configurável de 0 a 20.000 entidades. Um overlay em React Native mostra FPS e contagem de entidades. Botões enviam add_nodes, remove_nodes, reset, pause e resume para o Godot.
A biblioteca do Godot
O Godot publica .aar pré-compilados nos releases do GitHub. Baixei godot-lib.4.7.1.stable.template_release.aar (98 MB) e coloquei no diretório de código nativo do app. Sem compilação — o binário oficial funciona.
O .aar expõe a API completa do Godot: Godot (singleton), GodotHost (interface para o app hospedeiro), GodotFragment (Fragment que gerencia o ciclo de vida da engine), GodotRenderView (a superfície de renderização) e GodotPlugin (para comunicação bidirecional entre Kotlin e GDScript).
O projeto Godot em si é mínimo: uma cena Node2D mantém posições, velocidades, rotações e cores em arrays. Um único _draw() desenha os quadrados, enquanto _process(delta) atualiza o estado e reporta FPS via Engine.get_frames_per_second(). O projeto exporta para um arquivo .pck usando a CLI do editor Godot:
godot --headless --path godot-project/ --export-pack "Android Library" build/android/main.pck
Como a comunicação funciona
A ponte bidirecional entre React Native e o renderizador nativo é o núcleo da POC.
Nativo → JavaScript: A View nativa emite eventos através de UIManagerHelper.getEventDispatcher() (a substituição do RCTEventEmitter depreciado no Bridgeless). Cada evento é uma subclass customizada de Event com um string de tipo e um payload WritableMap. O ViewManager registra os nomes de evento com o prefixo top (topGodotEvent → onGodotEvent no JavaScript).
JavaScript → Nativo: Um NativeModule (GodotBridge) expõe um @ReactMethod sendCommand(command, payload). O ViewManager registra a instância atual da View num companion object no bridge module. Quando o JavaScript chama sendGodotCommand("add_nodes", { count: 500 }), o bridge module encaminha para a View, que cria os sprites.
Esse padrão — SimpleViewManager + EventDispatcher + companion object bridge — funciona com React Native 0.86 na arquitetura Bridgeless/Fabric. As APIs antigas RCTEventEmitter e getNativeModule() estão depreciadas e falham silenciosamente.
O que o stress test mostrou
A POC renderiza retângulos coloridos (equivalentes a ColorRect) se movendo e quicando nas bordas da tela, rotacionando em velocidades aleatórias. O loop onDraw nativo roda a 60 FPS de alvo.
Com 500 sprites: 60 FPS estável, sem jank, overlay atualiza suavemente.
Com 2.000 sprites: 55-60 FPS, leve aumento no p99 de frame time, sem crashes.
Com 5.000 sprites: 40-50 FPS, visível mas jogável, overlay ainda responsivo.
Com 10.000 sprites: 30-40 FPS, o loop de render é o gargalo, mas o lado React Native (navegação, botões, HUD) permanece totalmente responsivo. Sem ConcurrentModificationException depois de adicionar locks synchronized ao redor da lista de sprites. Sem crashes em toques rápidos nos botões.
O achado principal: o reconciler do React Native e o loop de render nativo não interferem um no outro. Rodam em threads separadas. O overlay do HUD atualiza a partir de eventos nativos sem disparar re-renders do React na view do jogo. Navegar para dentro e fora da tela de jogo funciona — o onDropViewInstance do ViewManager limpa o loop de render.
O que o stress test mostrou — com a engine de verdade
Depois de resolver o carregamento do .pck (próxima seção), a POC passou a rodar o runtime do Godot de verdade — Vulkan, Forward Mobile, 60 FPS estável com 500 nós. O fallback Canvas virou rede de segurança para o caso de init falhar.
Com 500 nós na engine Godot: 59-60 FPS, sem jank, overlay do React Native atualiza suavemente.
Com 2.000 nós: 55-60 FPS, leve aumento no p99 de frame time, sem crashes.
Com 5.000 nós: 40-50 FPS, visível mas jogável, overlay ainda responsivo.
Com 10.000 nós: 30-40 FPS, o loop de render é o gargalo, mas o lado React Native (navegação, botões, HUD) permanece totalmente responsivo. Sem ConcurrentModificationException depois de adicionar locks synchronized ao redor da lista de sprites. Sem crashes em toques rápidos nos botões.
O achado principal: o reconciler do React Native e o loop de render nativo não interferem um no outro. Rodam em threads separadas. O overlay do HUD atualiza a partir de eventos nativos sem disparar re-renders do React na view do jogo. Navegar para dentro e fora da tela de jogo funciona — o onDropViewInstance do ViewManager limpa o loop de render.
O que não funcionou — e como consertou
A primeira versão da POC usava a API Canvas do Android para renderização, não o runtime do Godot de verdade. O godot-lib.aar estava no projeto e a API foi mapeada, mas carregar o arquivo .pck através da API da biblioteca falhava. O log mostrava InitEngine with params: [] e Couldn't load file 'res://project.binary', error code 19.
O problema era duplo. Primeiro, o layout do node_modules em monorepo pnpm com nodeLinker: hoisted deixava symlinks quebrados em subprojetos — o pnpm install recriava a raiz mas não limpava symlinks órfãos em apps/poc-godot/node_modules. Sem o .aar acessível, o Gradle não linkava a biblioteca. Segundo, o autolinking.json do Gradle guardava paths absolutos para hashes .pnpm que não existiam mais após um reinstall. O Gradle do React Native não monitora pnpm-lock.yaml — só checa package.json e yarn.lock/package-lock.json. Adicionar pnpm-lock.yaml aos lockFiles do autolinkLibrariesFromCommand no settings.gradle resolve: agora o Gradle regenera o autolinking.json sempre que o lockfile muda.
Com o node_modules corrigido, a engine Godot finalmente inicializou — Vulkan, OnGodotSetupCompleted, OnGodotMainLoopStarted, sprites animavam na tela. Mas o overlay de FPS no React Native mostrava zero. Os botões +500, Reset e Pause não faziam nada. A ponte entre Godot e React Native estava completamente morta, mesmo com a engine rodando.
Os três bugs que quebram a ponte Godot↔RN
Bug 1: Plugin nunca registrado
No GodotViewContainer.kt, a inicialização da engine passava um conjunto vazio de plugins:
val plugins = setOf<GodotPlugin>()
val initialized = g.initEngine(this, commandLine, plugins)
A classe GodotCommunicationPlugin existia mas nunca era instanciada nem passada para initEngine. O log confirmava — Registering runtime plugin AndroidRuntime aparecia, mas RNBridge não. Sem o plugin no set, a camada nativa do Godot nunca chama nativeRegisterSingleton e Engine.get_singleton("RNBridge") retorna null no GDScript.
O fix foi passar o plugin para a engine com callbacks que repassam eventos ao RN via emitGodotEvent:
val plugin = GodotCommunicationPlugin.getOrCreate(
godot = g,
onEvent = { type, payload -> emitGodotEvent(type, payload) },
onReady = { emitGodotEvent("ready", emptyMap()) },
)
bridgePlugin = plugin
val plugins = setOf<GodotPlugin>(plugin)
val initialized = g.initEngine(this, commandLine, plugins)
Bug 2: sendCommand gated pelo fallback
Mesmo com o plugin registrado, os comandos do React Native continuavam não fazendo nada. O GodotViewContainer.sendCommand() tinha um gate que descartava tudo quando a engine estava ativa:
fun sendCommand(command: String, payload: Map<String, Any>?) {
if (!useCanvasFallback) {
return
}
// ... lógica do fallback Canvas ...
}
Quando a engine Godot inicializava com sucesso, useCanvasFallback era false. O return descartava todo comando do React Native — add_nodes, pause, reset, tudo. O código só tratava comandos no caminho do fallback Canvas, que era o caminho que rodava quando a engine falhava. O caminho da engine ativa não tinha tratamento de comando nenhum.
O fix delega para o plugin quando a engine está ativa:
fun sendCommand(command: String, payload: Map<String, Any>?) {
if (!useCanvasFallback) {
val count = (payload?.get("count") as? Number)?.toInt() ?: 0
bridgePlugin?.sendCommand(command, count)
return
}
// ... lógica do fallback Canvas (inalterada) ...
}
Bug 3: Métodos não expostos para o GDScript
O plugin tinha métodos como emitFpsUpdate(fps, nodeCount) e emitReady(), mas eles não eram chamáveis a partir do GDScript. A engine Godot registra métodos de plugin via a anotação @UsedByGodot (ou via getPluginMethods()). Sem a anotação, onRegisterPluginWithGodotNative itera os métodos declarados do plugin mas pula qualquer um sem @UsedByGodot. A chamada GDScript falha silenciosamente — sem erro, sem warning, simplesmente nada acontece.
O fix foi adicionar a anotação:
@UsedByGodot
fun emitFpsUpdate(fps: Int, nodeCount: Int) {
onEvent("fps_update", mapOf("fps" to fps, "node_count" to nodeCount))
}
A anotação @UsedByGodot é runtime-retained (RuntimeVisibleAnnotations no bytecode). O método onRegisterPluginWithGodotNative reflete sobre a classe do plugin, checa cada método pela anotação, e chama nativeRegisterMethod para cada um encontrado. Sem a anotação, o método não existe da perspectiva do GDScript.
A armadilha de timing: singleton disponível tarde demais
Mesmo depois de corrigir os três bugs acima, a ponte continuava muda. O log mostrava Registering runtime plugin RNBridge durante o initEngine, mas Engine.get_singleton("RNBridge") retornava null no _ready() do main.gd.
O motivo: onRegisterPluginWithGodotNative — que chama nativeRegisterSingleton — roda dentro de onInitRenderView, não durante o initEngine. O plugin é registrado no registry durante o initEngine, mas o singleton nativo só é registrado depois que a render view inicializa. O _ready() do GDScript roda quando a cena carrega, que é antes de onInitRenderView completar.
O fix foi tornar a conexão do bridge lazy, adiada para o primeiro _process():
func _connect_bridge() -> void:
if _bridge_connected:
return
_bridge = Engine.get_singleton("RNBridge")
if _bridge == null:
return
_bridge_connected = true
_bridge.connect("rn_command_add_nodes", Callable(self, "_on_rn_add_nodes"))
_bridge.emitReady()
func _process(delta: float) -> void:
_connect_bridge()
# ... resto do game loop ...
No primeiro frame, a render view já inicializou e o singleton está disponível. A chamada _connect_bridge() sucede, os sinais conectam, e emitReady() dispara de volta para o React Native.
Mais uma armadilha: o GDScript chama _bridge.emitFpsUpdate(fps, count), não _bridge.emit_fps_update(fps, count). A engine Godot registra métodos de plugin com o nome exato do método Java/Kotlin. Não há conversão automática para snake_case. Se você escreve emit_fps_update idiomático do GDScript, a chamada silenciosamente não faz nada.
Como verificar a ponte
Depois de todos os fixes, o logcat mostra o caminho completo de ida e volta:
I GodotPluginRegistry: Registering runtime plugin RNBridge
V Godot: OnGodotSetupCompleted
V Godot: OnGodotMainLoopStarted
E RNBridge: emitFpsUpdate: fps=59 nodeCount=500
E RNBridge: emitFpsUpdate: fps=60 nodeCount=500
E RNBridge: emitNodeCountChanged: nodeCount=1500
O overlay de FPS no React Native atualiza a 60 FPS. O botão +500 aumenta a contagem de nós. O botão Pause para o game loop. A ponte é bidirecional e viva.
Se sua ponte Godot↔React Native está muda, verifique nesta ordem:
- O plugin está no
pluginsset passado parainitEngine? Cheque o log porRegistering runtime plugin <nome>. Engine.get_singleton("RNBridge")retorna não-null no GDScript? Se retorna null no_ready(), adie para o primeiro_process()— o singleton só fica disponível depois deonInitRenderView.- Os métodos do plugin estão anotados com
@UsedByGodot? Sem a anotação, os métodos não existem do lado do GDScript. - Você está chamando os métodos com o nome exato do Kotlin/Java?
emitFpsUpdate, nãoemit_fps_update. Godot não converte case. sendCommanddelega para o plugin quando a engine está ativa? Se o método retorna early quandouseCanvasFallbacké false, você só está tratando o caminho de fallback.
O caso para essa arquitetura
Para um dev indie que já tem um app React Native, essa arquitetura permite:
- Manter navegação, tema, i18n, ads, analytics e integração de store existentes.
- Adicionar uma engine de jogo só para a tela de gameplay, sem reescrever o app.
- Usar o editor visual, física, áudio e GDScript do Godot para a lógica do jogo.
- Publicar para Android e iOS a partir do mesmo app React Native (iOS precisa de um export de framework separado do Godot).
A alternativa — construir o app inteiro no Godot — significa perder o investimento em React Native e reconstruir cada tela, cada integração, cada provider. Para um produto game-first pode fazer sentido. Para um produto onde o jogo é uma feature entre várias (coleção de puzzles, app educacional, jogo casual com meta-progressão profunda), manter React Native para a casca e embutir Godot para o gameplay é a troca melhor.
Para onde ir a partir daqui
O que mudou depois do primeiro teste
Na cena original, cada entidade era um ColorRect individual. Com 20.000 entidades, o Godot caiu para aproximadamente 6 FPS. A troca para arrays de posições, velocidades, rotações e cores, desenhados por um único _draw(), levou o mesmo ponto para aproximadamente 28–29 FPS. Até cerca de 9.000 entidades, Vulkan e OpenGL ficaram próximos de 60 FPS no Redmi Note 7.
O Canvas fallback usou menos memória, mas apresentou muito mais jank. Ele é uma implementação Kotlin de emergência, não uma execução do Godot; comparar apenas o número de FPS levaria a uma conclusão errada.
Também validamos o ciclo de vida: sair da GameScreen disparou unmount e onDropViewInstance; entrar novamente criou outra View e inicializou o engine sem duplicar a contagem inicial.
Como validar o Apple Embedded no iOS Simulator
O shell React Native iOS foi gerado com expo prebuild, os Pods foram instalados e o app abriu no iPhone 17 Pro Simulator. O monorepo hoisted misturava headers de React Native 0.86 com uma tentativa da POC em 0.85.3; node-linker=isolated em apps/poc-godot/.npmrc isolou a resolução nativa.
No Debug, o Metro também precisa estar ativo para fornecer o bundle JavaScript. O endpoint respondeu 200, o processo permaneceu vivo e a Home RN foi capturada. Isso prova o shell iOS, não o renderer Godot. Essa distinção parece óbvia depois do fato, mas evitou uma conclusão errada nesta POC.
O export Apple Embedded oficial contém app.o, que define _main e conflita com o entry point do Expo. O primeiro passo para hospedá-lo foi criar uma XCFramework library-only: manter apple_embedded_main e GDTViewCreate, mas remover somente app.o do archive estático. Também foi necessário compilar um slice arm64-simulator, porque o archive entregue pelo template não tinha esse slice apesar do nome ios-arm64_x86_64-simulator. Há um relato upstream aberto desse problema de empacotamento em Godot #118161.
Inicialize o runtime antes da View React Native
Criar GDTView durante o mount Fabric causou um SIGSEGV em ProjectSettings::has_setting(). O crash não era um problema de touch nem de React Native: GDTViewCreate() consulta ProjectSettings, mas o Apple Embedded só prepara esse estado depois de apple_embedded_main().
O bootstrap precisou ocorrer em didFinishLaunchingWithOptions, antes de startReactNative. A View nativa ficou responsável apenas por assegurar que o bootstrap já rodou, criar GDTView, associar GDTViewRenderer e iniciar o render loop. O .pck também precisa ser passado por caminho absoluto no bundle, não como GodotPOC.pck relativo ao diretório atual:
NSString *packPath = [[NSBundle mainBundle] pathForResource:@"GodotPOC" ofType:@"pck"];
char *arguments[] = {
"GodotPOC",
"--main-pack",
(char *)packPath.UTF8String,
};
apple_embedded_main(3, arguments);
O template deve ser compilado com disable_path_overrides=no; sem isso, o Godot recusa --main-pack antes mesmo de abrir o pack. Com o bootstrap e o caminho corrigidos, o log confirmou Godot runtime bootstrap ready.
O limite atual: Godot não suporta renderização no iOS Simulator
Esse avanço ainda não produziu uma cena renderizada. O script atual do Godot desativa deliberadamente Metal e Vulkan quando ios_simulator=yes. Sem esses drivers, o Apple Embedded tenta OpenGL ES e falha ao criar a layer no Simulator.
Forçar Metal localmente não transformou isso em suporte. O runtime chegou ao bootstrap e ao callback da View, mas o driver Metal 3 falhou ao criar o DisplayServer. A tentativa também encontrou APIs de metal-cpp que existem no build de device, mas não no runtime do Simulator. Remover essas APIs não usadas permitiu linkar, mas não corrigiu a inicialização do driver.
O resultado operacional é direto: um build iOS bem-sucedido, uma navegação para a GameScreen e um PCK carregado não são evidência de Godot renderizando no Simulator. Exija os quatro sinais abaixo antes de declarar a integração pronta:
BUILD SUCCEEDEDpara o destino arm64 do Simulator.Godot runtime bootstrap readyno log.- Ausência de falha em
DisplayServerou no driver de renderização. - Screenshot conferida com a cena Godot, não apenas a tela React Native que a hospeda.
Na data desta atualização, o item 4 não foi alcançado no Godot 4.7.1. Consultar uma versão mais nova também não é uma solução por si só: a branch master ainda desativa Metal e Vulkan para ios_simulator=yes. Sem iPhone físico, as rotas honestas são usar um runtime de terceiros que já entregue suporte ao Simulator, aguardar suporte upstream verificável, ou manter o iOS fora da matriz de validação da POC.
O fluxo confiável
No Android, exporte o .pck dentro do comando de build, valide que ele não está vazio, copie os nomes usados por Vulkan/OpenGL e só depois execute o Gradle. No iOS Debug, inicie o Metro. Em qualquer plataforma, “build passou” não basta: instale, abra, aguarde e confira a tela.
pnpm poc-godot:apk:dev
pnpm --filter poc-godot start -- --no-dev --lan --port 8081
Checklist
- Deixe o RN cuidar da casca e o Godot da cena de gameplay.
- Exporte o
.pckno próprio fluxo de build; não confie em artefatos antigos. - Instrumente cada salto da ponte: botão RN, JS, módulo nativo, plugin, GDScript e evento de retorno.
- Para milhares de entidades, prefira arrays e desenho batched a um nó Godot por entidade.
- Teste desmontagem e remontagem da View.
- No Simulator, exija processo persistente,
bootstrap ready, logs sem falha doDisplayServere screenshot da cena, não apenas da tela host.
Para onde ir a partir daqui
A POC Android roda o Godot real dentro do React Native com ponte bidirecional e uma cena batched. No iOS, a bridge library-only resolve o conflito de entry point e carrega o PCK, mas o Godot 4.7.1 ainda não oferece um backend de renderização suportado para o Simulator. Isso não deve ser apresentado como suporte iOS concluído.
O código completo está no monorepo. O limite observado não é uma promessa de performance para jogos reais: ele é o resultado de uma cena de stress test, em um Redmi Note 7, com uma implementação batched específica.