✂️

Flutter × Godot の統合をやめて分離した話 — 失敗から学んだアーキテクチャ判断

に公開

この記事は、Claude Code のクロウ🐦 くんが Holonia プロジェクトのコードベースと ADR をもとに執筆し、開発者 @tyabu12 が内容をレビュー・監修したものです。コードサンプルはすべて実際のプロジェクトコードから引用しています。

レビューで変わったところ(12回の修正)
  • Godot 未経験者向けの用語補足(GDScript, AutoLoad の説明追加)
  • ピボット判断のタイムライン明示(「5日間と約2,100万トークン」)
  • SurfaceView 問題の噛み砕き説明追加
  • UI 重複のトレードオフに対する運用カバー(共通デザインシステム)の補足
  • 著者のバックグラウンド追記(ゲームバックエンド経験 + 全技術スタック初挑戦)

この記事は、Holonia の開発で得た知見をシリーズでお届けする連載の第1回です。

バーチャルワールドを作るなら、3D エンジンとアプリ UI を1つにまとめたい — そう考えるのは自然ですよね。私たちもそう思って、Flutter の中に Godot を埋め込もうとしました。結論から言うと、やめました。

この記事は、バーチャルワールドプラットフォーム「Holonia」の開発で経験した、Flutter と Godot Engine の統合 → 分離の判断について書いたものです。「ゲームエンジンをアプリに埋め込めば最強じゃない?」と考えたことがある方に、ぜひ読んでいただきたいです。

Holonia — まだ実験中のバーチャルワールド

Holonia は、アバターが歩き回る 3D 空間とチャットやウォレット(実装予定)が一体になった「デジタルな居場所」を目指すプロジェクトです。一日中開きっぱなしにするデイリーユースアプリとして、消費電力を極力抑える設計を重視しています。まだリリース前の実証実験(PoC)段階ですが、技術スタックは本番を見据えて選定しました:

  • フロントエンド(モバイル/Web): Flutter + Riverpod
  • 3D 仮想空間: Godot Engine 4.6
  • バックエンド: Cloudflare Workers(Hono)+ D1 + Durable Objects + R2

補足ですが、@tyabu12 はゲームのバックエンド経験はありますが、Flutter・Godot・Cloudflare Workers はすべて初挑戦です。このプロジェクトは余暇の個人開発で、Claude Code とのバイブコーディングで進めています。

夢のハイブリッド構想

最初の設計では、1つのアプリで全部やるつもりでした。

Flutter でアプリの外殻(ルーティング、認証、チャット UI、ウォレット)を作り、その中に Godot の 3D ビューを埋め込みます。ユーザーがチャット画面からアバター空間にシームレスに切り替えられる — そんな世界観ですね(このアーキテクチャは後のセクションで図解しています)。

具体的には、godot_flutter というカスタム Flutter プラグインを作りました。やったことを整理するとこうなります:

プラットフォームごとのレンダリング戦略

プラットフォーム 方式 Flutter 側のウィジェット
Android GodotFragment を Platform View として埋め込み AndroidView
iOS LibGodot(migeran フォーク)経由 FallbackAvatarView(暫定)
macOS / Windows / Linux LibGodot C API で dlopen → Flutter Texture に描画 Texture / フォールバック

ブリッジ通信

Flutter と Godot の間を Platform Channel でつなぎました:

MethodChannel  holonia.dev/godot_bridge   (Flutter → Godot)
EventChannel   holonia.dev/godot_events   (Godot → Flutter)

Android なら Flutter → MethodChannel → Kotlin プラグイン → GodotBridgePlugin → GDScript(Godot 専用の Python ライクなスクリプト言語)の AutoLoad(アプリ全体で状態を保持するシングルトンの仕組み)が受け取る、という流れです。iOS は Swift、デスクトップは C++ と、プラットフォームごとに別のネイティブコードが必要でした。

GDScript 側では GodotBridge という AutoLoad シングルトンを用意して、プラットフォームの違いを吸収する設計にしました。Godot のシーンスクリプトはこのシングルトンの command_received シグナルを listen するだけ、という構造です。

ここまでは理想的でした

設計図だけ見ると、なかなか良さそうですよね。Flutter の得意な UI 開発で日常機能を、Godot の 3D 描画力でアバター空間を、1つのアプリに収められます。ユーザーにとってはインストールが1回で済みますし、開発者にとっては状態管理が一元化できます。

「これ、すごくいいんじゃない?」と思っていました。

問題は、実際に手を動かし始めてから見えてきました。

何がうまくいかなかったか

1. プラットフォームごとのネイティブコードが重すぎる

5つのプラットフォーム(Android / iOS / macOS / Windows / Linux)それぞれに、異なる言語(Kotlin / Swift / C++)でブリッジコードを書く必要がありました。

しかも相手は LibGodot — Godot 4.6 で導入された、Godot エンジンをライブラリとして外部アプリケーションに埋め込むための仕組みです。従来は Godot をスタンドアロンで実行するしかなかったところを、ホストアプリの中で起動・制御できるようにするという、非常に意欲的な取り組みです。

ただし、私たちが統合を試みた時点では、LibGodot の対応プラットフォームは Linux / Windows / macOS のデスクトップに限られていました。モバイル(Android / iOS)での UI 埋め込みは migeran によるモバイル対応の先行実装にしか存在せず、API サーフェスも安定していませんでした。5プラットフォーム分のネイティブ統合コードを、まだ発展途上の API に対してメンテし続けるのは現実的ではありませんでした。

2. ブリッジが「動くふり」をしていた

macOS のコマンドブリッジはスタブでした。LibGodot の公式 PR を見ると、UI 埋め込みは Apple プラットフォームで先行実装されていましたが、GDExtension の call-into-script(外部から GDScript の関数を呼ぶ仕組み)はまだサポートされていなかったからです。つまり、Flutter から Godot に命令を送る仕組みが、実はどのプラットフォームでも本番品質で動いていなかったんです。

双方向通信が「証明されていない」状態で先に進むのは、砂の上に家を建てるようなものですよね。

3. レンダリングパイプラインの衝突

Flutter と Godot は、それぞれ独自のレンダリングパイプラインを持っています。2つのエンジンが1つのウィンドウの中で描画を取り合うと、厄介な問題が起きます。

特に Android の SurfaceView 問題は深刻でした。Godot が SurfaceView を使ってレンダリングすると、Flutter のウィジェットをその上にオーバーレイできません。SurfaceView は通常のビュー階層とは別のレイヤーで描画されるので、Z オーダーの制御が効かないんです。要するに、Flutter の UI と Godot の 3D 画面を綺麗に重ねて表示することができませんでした。結局、アバター画面は専用のフルスクリーンルートにせざるを得ませんでした — 「シームレスな統合」という当初の目標が崩れた瞬間です。

4. 2つのエンジンが1バイナリに入るコスト

Flutter ランタイム + Godot エンジン + ゲームアセットが1つのバイナリに入ります。アプリサイズは肥大化しますし、起動時間は長くなりますし、メモリ消費も増えます。「毎日開くアプリ」を目指しているのに、起動のたびに待たされるのは致命的です。

5. デバッグの地獄

バグが出たとき、それが Flutter 側の問題なのか、Godot 側の問題なのか、ブリッジの問題なのかを切り分けるのが異常に難しかったです。2つのエンジンのログが混在しますし、ブレークポイントはネイティブ境界を越えられません。「あれ、これどっちのバグ?」を毎回やることになります。開発体験としては最悪の部類ですね。

「やめる」という判断

5 日間と約 2,100 万トークンの消費の試行錯誤の末、一歩引いて冷静に状況を見つめ直しました。

Flutter シェルが Godot に提供していた価値は何か? ルーティング、認証 UI、ミニアプリのホスティング。 どれも、Godot を埋め込まなくても実現できるものばかりでした。

逆に、Godot が必要としていたのは 3D レンダリングとリアルタイムのアバター制御 — これは Flutter なしでも完結します。

冷静に考えると、2つを無理に1つにする理由が実はなかったんです。

ピボット:2つの独立したアプリへ

そこで、思い切ってアーキテクチャを分離しました:

アプリ スタック 役割
Holonia Pocket Flutter + Riverpod 日常コンパニオン:チャット、ウォレット、ミニアプリ。Phase 1 は iOS / Android / Web
Holonia World Godot Engine 4.6(ネイティブ) 3D 仮想空間:アバター、リアルタイムプレゼンス。UI はすべて Godot ビルトイン。PC + モバイル

godot_flutter プラグインと Platform Channel のブリッジコードはすべて削除しました。world/ ディレクトリは自己完結した Godot プロジェクトになって、エディタから直接実行できるようになりました。

共通バックエンドが分離を可能にした

この分離が成り立つのは、バックエンドが共通だからです。

Cloudflare Workers 上の Hono アプリが、REST API と WebSocket エンドポイントを両方のアプリに提供しています:

/api/users     — ユーザー管理
/api/chat      — チャット
/api/wallets   — ウォレット
/api/ws/chat   — チャット用 WebSocket
/api/ws/world  — ワールド用 WebSocket

ユーザーアカウント、ウォレット残高、チャット履歴はすべて D1 データベースに統一されているので、Pocket でチャットした内容は World からも見えますし、その逆も同じです。

WebSocket エンドポイントの設計

面白いのは、WebSocket のエンドポイント設計です。チャットとワールドプレゼンスで別々の Durable Object(Cloudflare のステートフルなサーバーレスオブジェクト)を使い分けています:

  • チャット (/api/ws/chat/:room_id) → ChatRoomDO — ルーム ID で特定される DO インスタンスです。Pocket と World の両方から同じルームに接続できます。
  • ワールドプレゼンス (/api/ws/world/:scene_id) → WorldPresenceDO — シーン ID + セル座標 + リージョンで特定されます。アバターの位置同期を担当しています。

以下はワールドプレゼンス用の WebSocket 接続を受け付けるルートの例です[1]。クライアントから渡される scene_id をもとに、対応する Durable Object インスタンスにリクエストを転送する仕組みになっています:

backend/src/routes/ws.ts
// WebSocket upgrade route for World presence
wsRoute.get("/world/:scene_id", async (c) => {
  const sceneId = c.req.param("scene_id");
  const userId = c.req.query("user_id") ?? "anonymous";

  // Server-side lookup — never trust client-provided display_name/handle.
  const user = await getUserById(createDb(c.env.DB), userId);

  // Phase 1: cell is always 0:0 and region is "default".
  const cellX = Number.parseInt(c.req.query("cell_x") ?? "0", 10) || 0;
  const cellZ = Number.parseInt(c.req.query("cell_z") ?? "0", 10) || 0;
  const region = "default";

  const doName = derivePresenceDoId(sceneId, cellX, cellZ, region);
  const doId = c.env.WORLD_PRESENCE_DO.idFromName(doName);
  const stub = c.env.WORLD_PRESENCE_DO.get(doId);

  const url = new URL(c.req.url);
  url.searchParams.set("user_id", userId);

  return stub.fetch(new Request(url.toString(), c.req.raw));
});

ポイントは、Pocket からのチャット WebSocket と World からのプレゼンス WebSocket が、同じ Workers アプリを通じて同じ D1 を参照していることです。クライアントが Flutter か Godot かはバックエンドにとって関係ありません — HTTP と WebSocket のプロトコルさえ合っていれば、どんなクライアントでも動きます。

Before / After を比較する

Before(統合アーキテクチャ)

問題点:

  • ブリッジコードが5プラットフォーム × 3言語
  • レンダリングパイプラインが衝突
  • バイナリサイズ肥大化
  • デバッグ困難

After(分離アーキテクチャ)

利点:

  • 各アプリが得意なことに集中
  • ブリッジコード ゼロ
  • 独立したリリースサイクル
  • デバッグが圧倒的に楽

分離して何が良くなったか

1. 各フレームワークが本来の力を発揮できる

Flutter は UI ツールキットとして最高です。チャット画面、ウォレット、リスト表示 — Material Design のウィジェットでサクサク組めます。無理に 3D ビューを組み込む必要はありません。

Godot は 3D エンジンとして最高です。エディタから直接シーンを実行できますし、ビルトイン UI ノード(Control, VBoxContainer, Label など)でゲーム内 UI も十分に作れます。Flutter に頼る必要はありませんでした。

2. 開発速度が劇的に上がった

統合時代は「Godot のシーンを変更 → エクスポート → Flutter プラグインに配置 → ビルド → テスト」というサイクルでした。分離後は「Godot エディタで Cmd+R を押すだけ」です(Windows の場合は F5)。この差、体感するととても大きいですよ。

3. テストが簡単になった

Flutter のテストは Flutter の中で完結します。Godot のテストは GDUnit4 で完結します。ネイティブブリッジを跨いだテストという悪夢から解放されました。

CI もシンプルになりました。GitHub Actions のパスベースフィルタリングで backend/pocket/world/ それぞれが独立してテスト・デプロイされます。変更したところだけ CI が回るので、無駄な待ち時間もありません。

4. パフォーマンスが改善

2つのレンダリングエンジンを同時に動かすオーバーヘッドがなくなりました。World はアバター空間の描画に全リソースを使えますし、Pocket は軽量な 2D UI アプリとして省電力で動きます。毎日使うアプリだからこそ、バッテリーへの優しさは大事ですよね。

唯一のトレードオフ:UI の重複

分離のデメリットは明確で、チャットやウォレットの UI を 2回作る 必要があります。Flutter でも Godot でも。

ただし、ビジネスロジックとデータモデルはバックエンド API として一度だけ実装されています。重複するのは UI レイヤーだけです。そして正直なところ、ブリッジコードのメンテナンスに費やしていた時間を考えると、UI を2回書くほうがトータルコストは低いです。

さらに、両アプリで共通の UI デザインシステム(カラーパレット、スペーシング、ブラー量などのデザイントークン)を定義しているので、「見た目が全然違う」という事態にはなりません。UI のガワを各フレームワークで組むだけで、設計の一貫性はデザインシステムが担保してくれます。

他のプロジェクトへのアドバイス

Flutter + ゲームエンジンの統合を検討しているなら、こんな観点で考えてみてください:

統合が向いているケース

  • ゲームエンジンの機能が小さな領域に限定されている(例:AR ビューア、ミニゲーム1つ)
  • 単一プラットフォームのみをターゲットにしている
  • チーム内にネイティブプラットフォーム開発の専門家がいる

分離が向いているケース

  • ゲームエンジン部分がアプリの中核機能である(私たちのケースです)
  • 複数プラットフォームをサポートする必要がある
  • ゲームエンジン側にも独自の UI(チャット、設定画面など)が必要
  • チームが少人数で、ネイティブブリッジのメンテに人手を割けない

判断の基準

こう自問してみてください:

「Flutter が Godot に提供している価値は何か? それは、統合のコストに見合うか?」

私たちの場合、Flutter が提供していたのは「ルーティングと認証 UI」だけでした。それは Godot のビルトイン UI でも実現できるものでした。統合のコスト(5プラットフォーム分のネイティブコード、不安定な API への依存、デバッグの複雑さ)に見合いませんでした。

「やめる」は負けじゃない

エンジニアとして、私たちが作ったものを捨てるのは辛いです。godot_flutter プラグインには、それなりの時間と労力を注ぎました(約2,100万トークン、API利用料にして$34相当ほど笑)。Platform Channel のブリッジ設計を考え、フォールバック機構まで実装しました。

でも、動かないものを抱え続けるほうがもっと辛いんです。

ADR(Architecture Decision Record)として判断の理由を記録し、マイグレーション手順を書いて、きれいにプラグインを削除しました。判断の経緯をドキュメントに残しておくと、後から「なぜこうなったんだっけ?」と迷わずに済むのでおすすめです。

分離後、開発速度は目に見えて上がりました。Step 4(リアルタイム同期)は分離アーキテクチャの上でスムーズに完了し、今は Step 5(スーパーアプリ機能)に取り組んでいます。

振り返ると、「統合をやめる」という判断こそが、このプロジェクトで最も重要なアーキテクチャ判断でした。 技術的にクールなことを追い求めるより、ユーザーに価値を届けることに集中する — その転換点でした。

もし Flutter + ゲームエンジンの統合に苦しんでいる方がいたら、「やめる」という選択肢も真剣に検討してみてください。それは後退ではありません。正しい方向への一歩です。


次回は、分離した Godot 側で「ゲームじゃないアプリ」をどう設計したかをお話しします。実際に触ってみると、Godot のビルトイン UI は Web の Flexbox に通じるレイアウトシステムを持っていて、コードでの UI 構築が思った以上にスムーズでした。Web フロントエンドの経験がある方には、きっと馴染みのある話になるはずです。お楽しみに。

脚注
  1. 実際のコードからエラーハンドリングや認証情報の取得処理を省略して抜粋しています。 ↩︎

Discussion