Pular para o conteúdo
← Voltar para o blog
Game Development

Como embutir Godot em um app React Native e medir o custo real

17 min read

Escrito 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:

Imagem de prévia OpenGraph deste artigo. Como embutir Godot em um app React Native e medir o custo real

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: SettingsProviderSoundProviderVibrationProviderAnalyticsProviderStoreProviderAdsProviderToastProviderModalProviderAppNavigator.

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 (topGodotEventonGodotEvent 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:

  1. O plugin está no plugins set passado para initEngine? Cheque o log por Registering runtime plugin <nome>.
  2. 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 de onInitRenderView.
  3. Os métodos do plugin estão anotados com @UsedByGodot? Sem a anotação, os métodos não existem do lado do GDScript.
  4. Você está chamando os métodos com o nome exato do Kotlin/Java? emitFpsUpdate, não emit_fps_update. Godot não converte case.
  5. sendCommand delega para o plugin quando a engine está ativa? Se o método retorna early quando useCanvasFallback é 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:

  1. BUILD SUCCEEDED para o destino arm64 do Simulator.
  2. Godot runtime bootstrap ready no log.
  3. Ausência de falha em DisplayServer ou no driver de renderização.
  4. 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

  1. Deixe o RN cuidar da casca e o Godot da cena de gameplay.
  2. Exporte o .pck no próprio fluxo de build; não confie em artefatos antigos.
  3. Instrumente cada salto da ponte: botão RN, JS, módulo nativo, plugin, GDScript e evento de retorno.
  4. Para milhares de entidades, prefira arrays e desenho batched a um nó Godot por entidade.
  5. Teste desmontagem e remontagem da View.
  6. No Simulator, exija processo persistente, bootstrap ready, logs sem falha do DisplayServer e 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.