Open Source

Flutter package

unity_kit

Embed Unity 3D in Flutter with a typed, testable bridge - JSON or binary protocol, an enforced lifecycle, AR Foundation, and CDN asset streaming. Runs on Android and iOS (web/WebGL experimental).

v2.1.119.1k / month24 likesMITUnity 2022.3 LTS → Unity 6

Migrating from flutter_unity_widget

Every API, callback, default and Unity-side script mapped from flutter_unity_widget to unity_kit, with the behaviour changes that bite silently.

Orientation

What actually changes

The mental model is the same: Unity renders into a native platform view embedded in your Flutter tree, and the two sides talk over a serialized bridge. Three things change, and everything else in this guide follows from them.

Controller becomes a bridge you own

The controller used to be born and buried with the widget, so navigating away killed communication. The bridge is a plain object you create in a service layer and hand to the widget, and UnityView never disposes a bridge it did not create.

Raw strings become typed messages

dynamic payloads and @UnityMessage@ prefixes are replaced by UnityMessage with a type and a data map, plus a binary codec when JSON is too slow.

Callbacks become streams and a state machine

Instead of polling isReady() and hoping a send lands, there is an enforced lifecycle, a readiness queue and typed exceptions (EngineNotReadyException, LifecycleException).

Migrate in two passes

The Dart side and the Unity side can move separately. If your C# still sends plain strings, the bridge tries to parse JSON, fails, and falls back to UnityMessage(type: <the whole string>) with a null data. Nothing is dropped, so you can ship the Flutter migration first and port the C# handlers afterwards.

Reference

API mapping

The full translation table. Anything marked as having no equivalent is a capability that did not exist before, not something you have to replace.

flutter_unity_widgetunity_kitNotes
flutter_unity_widgetunity_kitSingle package, no companion plugin to add.
UnityWidget(...)UnityView(...)Config moves out of the constructor into UnityConfig.
UnityWidgetControllerUnityBridge / UnityBridgeImplCreated outside the widget, survives navigation and rebuilds.
onUnityCreated: (controller)onReady: (bridge)Fires on UnityLifecycleState.ready, not on view creation, so it is safe to send immediately.
onUnityMessage: (dynamic)onMessage: (UnityMessage)Typed object with type and data, or subscribe to bridge.messageStream.
onUnitySceneLoaded: (SceneLoaded?)onSceneLoaded: (SceneInfo)Non-nullable, plus a bridge.sceneStream.
onUnityUnloaded: ()onEvent: (UnityEvent)Unload arrives as UnityEventType.unloaded alongside every other lifecycle event.
controller.postMessage(go, method, string)bridge.send(UnityMessage.to(go, method, data))Payload is a Map, serialized to JSON on the wire.
controller.postJsonMessage(go, method, map)bridge.send(UnityMessage.to(go, method, map))One method for both cases, no string/JSON split.
(no equivalent)bridge.sendWhenReady(message)Queues before Unity is up, auto-flushes on ready instead of dropping.
(no equivalent)bridge.sendBinary(message)Compact binary frame via UnityBinaryCodec for high-frequency traffic.
await controller.isReady()bridge.isReadySynchronous getter backed by the lifecycle state machine.
await controller.isPaused(), isLoaded(), inBackground()bridge.currentState / bridge.lifecycleStreamOne UnityLifecycleState enum replaces three nullable futures.
controller.pause(), resume(), unload()bridge.pause(), resume(), unload()Same names, but they return Future<void> instead of Future<void>?.
controller.quit()bridge.unload() / bridge.dispose()Not on the bridge on purpose: Unity cannot be restarted in-process after a quit. The raw call still exists as UnityKitPlatform.instance.quit().
controller.create()bridge.initialize()Transitions uninitialized to initializing; invalid transitions throw LifecycleException.
useAndroidViewSurface: true(always on)UnityView always uses Hybrid Composition on Android so Unity stays inside the widget bounds.
UnityMessageManager.Instance.SendMessageToFlutter(s)NativeAPI.SendToFlutter(json) / FlutterMonoBehaviour.SendToFlutter(type, data)Static call, no singleton MonoBehaviour to place in every scene.
UnityMessageManager.OnMessage / OnFlutterMessageFlutterMonoBehaviour.OnFlutterMessage(method, data)Routed by target name through MessageRouter, or dispatched by [UnityKitMethod].

Step 1

Swap the dependency and create the bridge

unity_kit needs Dart 3.4 / Flutter 3.22 because the web target uses modern dart:js_interop. Mobile-only consumers are otherwise unaffected.

Before: pubspec.yamlyaml
dependencies:
  flutter:
    sdk: flutter
  flutter_unity_widget: ^2022.2.1
After: pubspec.yamlyaml
environment:
  sdk: ">=3.4.0 <4.0.0"
  flutter: ">=3.22.0"

dependencies:
  flutter:
    sdk: flutter
  unity_kit: ^2.0.3

There is no plugin-level initialize() to call in main(). What you do add is a bridge, created once and shared, which is the single biggest structural difference between the two packages.

Bridge bootstrapdart
import 'package:unity_kit/unity_kit.dart';

// Create the bridge ONCE, outside the widget tree (service locator, DI, provider).
// This is the whole point of the migration: the bridge is not owned by a widget,
// so navigating away from the Unity screen no longer kills your connection.
final bridge = UnityBridgeImpl(platform: UnityKitPlatform.instance);
await bridge.initialize();

Step 2

UnityWidget to UnityView

Rendering flags move from the widget constructor into UnityConfig, and the callbacks are renamed and typed. onUnityCreated fired when the platform view existed; onReady fires when Unity is actually able to receive messages, so you can send from inside it.

Before: UnityWidgetdart
UnityWidget(
  onUnityCreated: (controller) => _controller = controller,
  onUnityMessage: (message) => debugPrint('From Unity: $message'),
  onUnitySceneLoaded: (scene) => debugPrint('Scene: ${scene?.name}'),
  onUnityUnloaded: () => debugPrint('Unloaded'),
  fullscreen: false,
  hideStatus: false,
  runImmediately: true,
  unloadOnDispose: true,
  useAndroidViewSurface: true,
  enablePlaceholder: true,
  placeholder: const Center(child: CircularProgressIndicator()),
)
After: UnityViewdart
UnityView(
  // Pass the bridge you created above. An external bridge is NEVER disposed
  // by the widget. Omit it and the widget creates + owns an internal one.
  bridge: bridge,
  config: const UnityConfig(
    sceneName: 'MainScene',
    fullscreen: false,
    hideStatusBar: false,
    runImmediately: true,
    unloadOnDispose: true,
    targetFrameRate: 60,
  ),
  placeholder: const Center(child: CircularProgressIndicator()),
  onReady: (bridge) => bridge.send(UnityMessage.command('StartGame')),
  onMessage: (message) => debugPrint('${message.type} ${message.data}'),
  onSceneLoaded: (scene) => debugPrint('Scene: ${scene.name}'),
  onEvent: (event) => debugPrint('Lifecycle: ${event.type}'),
)

Watch the changed defaults

Four flags share a name but not a default value, and two were dropped. Copying your old constructor across verbatim will change behaviour silently.

Flagflutter_unity_widgetunity_kitWhat to do
runImmediatelyfalsetrueUnity starts as soon as the view is created unless you opt out.
unloadOnDisposefalsetrueSet it to false if you keep Unity warm between screens.
enablePlaceholder + placeholderfalse(no flag)Pass placeholder and it is shown until the bridge reports ready. There is no separate on/off flag.
hideStatus to hideStatusBarfalsefalseRenamed only.
targetFrameRate(no equivalent)60New knob, passed to native as a creation param.
borderRadius, uiLevel, layoutDirection, printSetupLogvarious(dropped)Wrap UnityView in a ClipRRect for rounded corners; logging goes through UnityKitLogger.

Step 3

Rewrite the messaging

Sending splits into three intents instead of two overloads: send now, send when ready, or send as binary. All three take the same UnityMessage, so the choice is about timing and wire format, not about payload shape.

Before: postMessage / postJsonMessagedart
_controller.postMessage('GameManager', 'RotateCube', '360');
_controller.postJsonMessage('Player', 'TakeDamage', {'amount': 10});
After: bridge.send / sendWhenReady / sendBinarydart
// Same UnitySendMessage(gameObject, method) target as postMessage:
await bridge.send(UnityMessage.to('GameManager', 'RotateCube', {'angle': 360}));

// Sent before Unity is up? Queued by the readiness guard, flushed on ready:
await bridge.sendWhenReady(
  UnityMessage.to('Player', 'TakeDamage', {'amount': 10}),
);

// Routed through FlutterBridge + MessageRouter, no GameObject lookup needed:
await bridge.sendWhenReady(
  UnityMessage.routed('InventoryManager', 'AddItem', {'id': 'sword'}),
);

// High-frequency traffic (input, transforms): compact binary frame, not JSON:
await bridge.sendBinary(UnityMessage.command('Move', {'x': 1.0, 'y': 0.0}));

Your C# now receives an envelope, not the raw value

postMessage('GameManager', 'RotateCube', '360') delivered the literal string 360 to your method. UnityMessage.to still targets the same GameObject and method over UnitySendMessage, but the argument is the serialized envelope {"type":"RotateCube","data":{"angle":360}}. Parse it on the C# side, or port the handler to FlutterMonoBehaviour as shown in step 5.

Receiving is where the typed model pays for itself. The old callback handed you dynamic and left the framing to you; the new one hands you a parsed message, and the same messages are also available as a broadcast stream you can consume far away from the widget.

Before: onUnityMessagedart
onUnityMessage: (dynamic message) {
  // message is dynamic: usually a String, sometimes an @UnityMessage@ envelope
  final raw = message.toString();
  if (raw.startsWith('score:')) {
    setState(() => _score = int.parse(raw.split(':').last));
  }
}
After: onMessage + streamsdart
// Option A: the widget callback, typed
onMessage: (UnityMessage message) {
  switch (message.type) {
    case 'score_updated':
      setState(() => _score = message.data?['score'] as int? ?? 0);
    case 'game_over':
      _navigateToSummary();
  }
}

// Option B: subscribe anywhere, because the bridge outlives the widget
final subscription = bridge.messageStream
    .where((message) => message.type == 'score_updated')
    .listen((message) => scoreCubit.update(message.data?['score'] as int? ?? 0));

// Other typed streams, no polling required:
bridge.sceneStream.listen((SceneInfo scene) => ...);
bridge.eventStream.listen((UnityEvent event) => ...);
bridge.lifecycleStream.listen((UnityLifecycleState state) => ...);
bridge.performanceStream.listen((UnityPerformanceStats stats) => ...);

There is no built-in request/response envelope to replace MessageHandler.send(). The convention is an explicit callbackId field in your payload, correlated on messageStream. The streaming module does exactly this, so use UnityAssetLoader as the reference implementation.

Step 4

Lifecycle and state queries

Three nullable futures collapse into one enum plus a stream. Every transition is validated, so a send on a disposed bridge fails loudly with a typed exception instead of quietly doing nothing.

Before: polled controller statedart
final ready = await _controller.isReady();
final paused = await _controller.isPaused();
final loaded = await _controller.isLoaded();
final background = await _controller.inBackground();

await _controller.pause();
await _controller.resume();
await _controller.unload();
await _controller.quit();
_controller.dispose();
After: state machine + streamdart
final ready = bridge.isReady;                          // sync getter, no await
final state = bridge.currentState;                     // UnityLifecycleState
final paused = state == UnityLifecycleState.paused;

await bridge.pause();
await bridge.resume();
await bridge.unload();
await bridge.dispose();

// Instead of polling three futures, listen to the state machine:
bridge.lifecycleStream.listen((state) {
  if (state == UnityLifecycleState.ready) _onUnityUp();
});

Delete your WidgetsBindingObserver

Pausing Unity when the app backgrounds is handled natively now: a DefaultLifecycleObserver on Android and NotificationCenter observers on iOS. The Dart-side forwarding was removed on purpose, because the double round-trip could hang Unity. UnityLifecycleMixin is still exported for bridges you drive without a UnityView, but do not stack it on top of one or Unity gets paused twice.

Step 5

Swap the Unity-side scripts

The two integrations cannot coexist: both register a Flutter menu and both claim the native message symbols. Remove the old folder before copying the new one in.

Assets/FlutterUnityIntegration/
Assets/Scripts/UnityKit/

Delete the whole folder, including Plugins/ and JsonDotNet/, then copy unity_kit/unity/Assets/Scripts/UnityKit/ in.

Assets/FlutterUnityIntegration/Editor/Build.cs
Assets/Scripts/UnityKit/Editor/Build.cs

Both add a Flutter menu. Keep only one, or the menu items collide.

FlutterUnityIntegration/UnityMessageManager.cs
Assets/Scripts/UnityKit/FlutterBridge.cs + MessageRouter.cs

FlutterBridge auto-creates on scene load and is DontDestroyOnLoad.

Assets/Plugins/iOS/NativeCallProxy.*
Assets/Plugins/iOS/UnityKitNativeBridge.mm

Must sit in Assets/Plugins/iOS/ before the export or the iOS symbols are missing.

On the scripting side, one public method per command becomes one OnFlutterMessage switch per handler, and the Json.NET dependency goes away: unity_kit parses with Unity's built-in JsonUtility.

Before: UnityMessageManagercsharp
using FlutterUnityIntegration;   // ships JsonDotNet (Newtonsoft)

public class GameManager : MonoBehaviour
{
    // Invoked by UnitySendMessage, receives the raw string from postMessage
    public void RotateCube(string message)
    {
        var degrees = float.Parse(message);   // "360"
    }

    private void OnWaveCleared()
    {
        UnityMessageManager.Instance.SendMessageToFlutter("wave_cleared");
    }
}
After: FlutterMonoBehaviourcsharp
using UnityKit;   // no Newtonsoft, UnityKit uses JsonUtility

public class GameManager : FlutterMonoBehaviour
{
    // One entry point instead of one public method per command.
    // Auto-registers with MessageRouter under TargetName (GameObject name
    // by default, or the Target Name field in the inspector).
    protected override void OnFlutterMessage(string method, string data)
    {
        switch (method)
        {
            case "RotateCube":
                // data is the JSON payload: {"angle":360}
                var request = JsonUtility.FromJson<RotateRequest>(data);
                break;
        }
    }

    private void OnWaveCleared()
    {
        // Scalar payload: arrives in Dart as message.data['value']
        SendToFlutter("wave_cleared", "3");

        // Structured payload: build the full envelope yourself
        NativeAPI.SendToFlutter("{\"type\":\"score_updated\",\"data\":{\"score\":1500}}");
    }

    [System.Serializable]
    private class RotateRequest { public float angle; }
}

Casing is a contract

Commands from Flutter are PascalCase (LoadToy, StartGame), responses from Unity are snake_case (toy_loaded, game_started). The bridge does not translate for you, so a casing mismatch shows up as [UnityKit] No handler registered for target: xxx in the Unity console.

Step 6

Re-export and redeploy the native artifacts

The artifact layout is unchanged: Android gets android/unityLibrary, iOS gets ios/UnityLibrary. What changes is which Build.cs produces them, so a stale export from the old integration will keep the old native bridge alive and your messages will vanish.

Taskflutter_unity_widgetunity_kit
Export AndroidFlutter > Export AndroidFlutter > Export Android (Debug | Release)
Export iOSFlutter > Export IOSFlutter > Export iOS (Debug | Release)
Export webFlutter > Export WebFlutter > Export WebGL
Deploy into the Flutter projectManual copyFlutter > Settings to set the project path, then Flutter > Deploy to Flutter Project
Headless CI(none shipped)Unity -batchmode with -executeMethod UnityKit.Editor.Build.ExportAndroidRelease
Release strippingManual keep rulesExport writes proguard-unity.txt keep rules for com.unity_kit.** and com.unity3d.player.**

Full clean after the swap

Old Unity artifacts survive an incremental build. Delete android/unityLibrary and ios/UnityLibrary, re-export, then flutter clean && flutter pub get before running on a physical device. The iOS Simulator cannot load UnityFramework at all.

The full procedure, including the Gradle and Xcode wiring, lives in the Unity export guide.

Wrap up

Migration checklist

  1. 1Swap the dependency in pubspec.yaml and bump the Dart/Flutter constraints.
  2. 2Move bridge creation out of the widget into a service or DI container.
  3. 3Replace every UnityWidget with UnityView + UnityConfig, and re-check the defaults that changed.
  4. 4Rewrite sends: postMessage / postJsonMessage to UnityMessage.to / .routed, and prefer sendWhenReady on startup paths.
  5. 5Rewrite receives: parse message.type instead of sniffing raw strings.
  6. 6Replace polled isReady / isPaused / isLoaded with currentState or lifecycleStream.
  7. 7Delete Assets/FlutterUnityIntegration/ from the Unity project and copy Assets/Scripts/UnityKit/ in.
  8. 8Port each Unity handler to FlutterMonoBehaviour.OnFlutterMessage and switch sends to NativeAPI.SendToFlutter.
  9. 9Re-export from Unity (Flutter menu) and redeploy android/unityLibrary + ios/UnityLibrary.
  10. 10Verify on a physical device: iOS Simulator cannot run UnityFramework.

If something does not come up after the swap, the usual suspects are a stale native artifact, a target-name mismatch, or a send that happened before ready. All three are covered in the FAQ.

Let's make something together.

If you have any questions about a new project or other inquiries, feel free to contact us. We will get back to you as soon as possible.

Codigee
We are using cookies. Learn more