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.0.222.6k / month21 likesMITUnity 2022.3 LTS → Unity 6

Architecture

The overall design: bridge, lifecycle state machine, streams, exceptions and how the pieces fit together.

source: doc/architecture.md

Architecture

Overview

unity_kit is a Flutter plugin for Unity 3D integration. It provides typed bridge communication, lifecycle management, readiness guards, message batching/throttling, and asset streaming with cache management. The rest of this page walks the design layer by layer — from the public Dart API down to the native player — and the decisions behind each piece.

CategoryTechnologyVersion
LanguageDart>=3.0.0 <4.0.0
FrameworkFlutter>=3.0.0
Unity Bridgeflutter_unity_widget (conceptual)Custom native
Game EngineUnity2022.3 LTS+ / Unity 6000
Unity LanguageC#.NET Standard 2.1

Library architecture

The Dart side is a stack of modules with a single barrel export at the top. Each layer only depends on the one below it, and each has its own barrel file so the public API stays flat.

lib/ layerstext
Public API (lib/unity_kit.dart - barrel export)
    |
Widgets (lib/src/widgets/) - Flutter StatefulWidgets wrapping Unity PlatformView
    |
Bridge (lib/src/bridge/) - Abstract interface for Flutter <-> Unity communication
    |
Models (lib/src/models/) - Typed configs, messages, events, enums
    |
Streaming (lib/src/streaming/) - Asset download, caching, Addressables integration
    |
Platform (lib/src/platform/) - MethodChannel implementation, native interface
    |
Exceptions (lib/src/exceptions/) - Structured error types
    |
Utils (lib/src/utils/) - Internal constants, logging (NOT exported)

Key design principles

  • Abstract interface for the bridge — swappable for testing, mockable.
  • Factory constructors on models for common use cases.
  • Barrel exports at each module level plus a top-level library file.
  • Bridge independent of widget — the bridge is a service, the widget is just a view.
  • Streams for events messageStream, eventStream, sceneStream, lifecycleStream.

Bridge design

The bridge follows a layered composition pattern. UnityBridgeImpl is a facade that wires together the lifecycle state machine, readiness guard, message router, optional batcher/throttler, and the native platform channel.

Bridge compositiontext
UnityBridgeImpl (facade)
+-- LifecycleManager      (state machine with enforced transitions)
+-- ReadinessGuard         (queues messages until Unity is ready)
+-- MessageHandler         (routes messages by type to registered callbacks)
+-- MessageBatcher?        (optional - coalesces rapid messages)
+-- MessageThrottler?      (optional - rate-limits outgoing messages)
+-- UnityKitPlatform       (native communication via MethodChannel)

Ownership is explicit. A bridge can be created and owned externally by the caller, or created and disposed internally by the widget:

Bridge ownership modeldart
// External bridge (caller manages lifecycle):
final bridge = UnityBridgeImpl(platform: UnityKitPlatform.instance);
await bridge.initialize();
UnityView(bridge: bridge, ...); // Widget never disposes this bridge

// Internal bridge (widget manages lifecycle):
UnityView(bridge: null, ...); // Widget creates and disposes its own bridge

Why this matters

This solves the biggest pain point from flutter_unity_widget: the controller being destroyed when the widget rebuilds or is removed from the tree. Because the bridge is a standalone object, it survives widget disposal and navigation — the widget is only a view onto it.

Lifecycle state machine

The Unity player follows a strict state machine. Invalid transitions throw LifecycleException. Once the player reaches disposed it is terminal and cannot be reused.

State machinetext
                                +----------+
                                | disposed |
                                +----------+
                                  ^  ^  ^
                                  |  |  |
  +---------------+    +--------+ |  |  |  +--------+    +---------+
  | uninitialized |--->| init.. |-+  |  +--| paused |<-->| resumed |
  +---------------+    +--------+    |     +--------+    +---------+
                           |         |        ^
                           v         |        |
                        +-------+----+--------+
                        | ready |
                        +-------+

Valid transitions

FromTo
uninitializedinitializing
initializingready, disposed
readypaused, disposed
pausedresumed, disposed
resumedpaused, disposed
disposed(terminal — cannot reuse)

Communication protocol

Messages cross the bridge as JSON in both directions. Flutter targets a specific Unity GameObject and method; Unity replies either as typed events or as routed messages.

Flutter → Unity

Messages are sent via UnitySendMessage targeting a specific GameObject and method. The FlutterBridge C# singleton receives the message at ReceiveMessage(json), deserializes it, and routes it via MessageRouter to the handler matching target.

text
JSON: {"target":"ModelObject", "method":"Rotate", "data":"45"}
      |                       |                  |
      FlutterMessage.target   FlutterMessage.method  FlutterMessage.data

Unity → Flutter

Messages from Unity use two formats. Typed messages carry a type for general events; routed messages target a specific handler. Both arrive on the Dart side via bridge.messageStream.

Typed message (general events)json
{"type":"score_updated", "data":{"score":100}}
Routed message (targets a handler)json
{"target":"GameManager", "method":"OnResult", "data":"win"}

Scene notifications

Scene load/unload events are sent via a dedicated native call (NativeAPI.NotifySceneLoaded) and arrive on bridge.sceneStream as SceneInfo objects.

Platform layer

Each platform implements the same contract with native code. Both keep the Unity player alive through a singleton UnityPlayerManager and route Unity messages back to the right Flutter controller via FlutterBridgeRegistry.

Android

Kotlintext
Kotlin Code
+-- UnityKitPlugin          (FlutterPlugin entry point)
+-- UnityKitViewFactory      (PlatformViewFactory for AndroidView)
+-- UnityKitViewController   (PlatformView + MethodChannel handler)
+-- UnityPlayerManager       (Singleton Unity player lifecycle)
+-- FlutterBridgeRegistry    (Routes Unity messages to Flutter controllers)

iOS

Swift + native bridgetext
Swift Plugin Code (unity_kit/ios/Classes/)
+-- SwiftUnityKitPlugin       (FlutterPlugin entry point, view factory registration)
+-- UnityKitViewFactory       (FlutterPlatformViewFactory)
+-- UnityKitViewController    (FlutterPlatformView + MethodChannel handler)
+-- UnityKitView              (UIView container for Unity view)
+-- UnityPlayerManager        (Singleton Unity player lifecycle)
+-- FlutterBridgeRegistry     (Routes Unity messages to Flutter controllers)
+-- UnityEventListener        (Protocol for Unity event callbacks)

Native Bridge (compiled into UnityFramework, not in Flutter plugin)
+-- UnityKitNativeBridge.mm   (extern "C" symbols for IL2CPP DllImport)

Native bridge pattern: C# [DllImport("__Internal")] resolves to extern "C" symbols in UnityKitNativeBridge.mm, which is compiled into UnityFramework.framework. At runtime it forwards to FlutterBridgeRegistry via the ObjC runtime (NSClassFromString + performSelector). This indirection is necessary because the Flutter plugin and UnityFramework are separate binaries.

Auto-initialization: UnityKitViewController mirrors Android's auto-init pattern: init()waitForNonZeroFrame()autoInitialize() waitForUnityView(). The non-zero frame guard prevents Metal texture crashes (MTLTextureDescriptor has width of zero).

MethodChannel naming

Each platform view gets its own MethodChannel; events use a shared EventChannel.

text
// Per-view MethodChannel:
com.unity_kit/unity_view_{viewId}

// Shared EventChannel:
com.unity_kit/unity_events

Platform support

Production platforms are Android and iOS only. Web/WebGL is not production-ready, there is no desktop target, and iOS runs on a physical device only (no iOS Simulator).

Asset streaming pipeline

Streaming lets the app ship small and pull heavy Unity content from a CDN at runtime. StreamingController downloads bundles through ContentDownloader, verifies and caches them via CacheManager, then tells Unity to load from the local cache.

Streaming flowtext
StreamingController
+-- ContentDownloader       (HTTP downloads with retries, progress, cancellation)
|   +-- CacheManager        (local file cache with SHA-256 integrity)
+-- UnityBridge             (tells Unity to load from local cache)

Flow:
  Flutter                          Native Cache                  Unity
    |                                  |                           |
    |-- download bundle ------------->|                           |
    |                                  |-- write to disk          |
    |-- sendWhenReady(LoadAsset) -----|-------------------------->|
    |                                  |                           |
    |                                  |<--- Addressables checks  |
    |                                  |     local cache first    |
    |                                  |                           |
    |<------- asset_loaded response --|---------------------------|

Manifest format

manifest.jsonjson
{
  "version": "1.0.0",
  "baseUrl": "https://cdn.example.com/bundles",
  "catalogUrl": "https://cdn.example.com/bundles/catalog_2024.bin",
  "bundles": [
    {"name": "core", "url": "...", "sizeBytes": 5242880, "sha256": "...", "isBase": true},
    {"name": "toys_assets_alphie_abc123.bundle", "url": "...", "sizeBytes": 10485760, "dependencies": ["core"], "group": "addressables", "metadata": {"addressableKeys": ["alphie"]}}
  ]
}

Addressable key extraction

When loading a remote asset, StreamingController.extractAddressableKey() determines the Addressable address from a bundle entry in three steps:

  1. 1
    Metadata lookup — if bundle.metadata['addressableKeys'] exists (generated by AddressablesManifestBuilder), use the first key. This is the most reliable method.
  2. 2
    Regex extraction — strip the .bundle extension, remove the 32-character hex content hash suffix, then remove the group prefix (e.g. toys_assets_). A heuristic fallback.
  3. 3
    Raw name — if neither method produces a result, use the cleaned bundle name as-is.

The AddressablesManifestBuilder (Unity Editor tool) generates metadata.addressableKeys by matching Addressable group entries to built bundle filenames.

UnityKitLogger (C# logging)

All UnityKit C# scripts log through UnityKitLogger — a static class with delegate-based routing. By default it writes to Debug.Log with a [UnityKit] prefix; projects wire their own logger at startup, which decouples the synced scripts from project-specific logging.

csharp
// In project bootstrap (e.g. CryptoysLogger.cs):
UnityKitLogger.LogAction = msg => CryptoysLogger.Info(LogCategory.Bridge, msg);
UnityKitLogger.WarnAction = msg => CryptoysLogger.Warning(LogCategory.Bridge, msg);
UnityKitLogger.ErrorAction = msg => CryptoysLogger.Error(LogCategory.Bridge, msg);

Exception hierarchy

Errors are typed. Every exception derives from UnityKitException and carries message, an optional cause, and an optional stackTrace.

Exception treetext
UnityKitException (base)
+-- BridgeException              (bridge communication failures)
+-- CommunicationException       (message delivery failures, includes target/method context)
+-- EngineNotReadyException      (sending before Unity is ready)
+-- LifecycleException           (invalid state transitions, includes current state)

Design decisions & alternatives

Every layer above exists to fix a concrete problem seen in existing Unity-in-Flutter approaches. Each decision below states the problem and the solution unity_kit ships.

1. Bridge independent of widget

Problem: In flutter_unity_widget, the controller is tied to the widget. Navigating away destroys the controller, losing the ability to communicate with Unity.

Solution: The bridge is a standalone object. It can be created in a service layer, injected into widgets, and survives widget disposal. UnityView accepts an optional external bridge; if provided, it never disposes it.

2. Readiness guard with message queuing

Problem: Messages sent before Unity is initialized are silently dropped.

Solution: ReadinessGuard provides two modes: guard() throws immediately, queueUntilReady() queues messages and auto-flushes when the engine reports ready.

3. Typed messages instead of raw strings

Problem: flutter_unity_widget uses raw strings for communication. No structure, no type safety.

Solution: UnityMessage with type, data, gameObject, method fields and factory constructors (command, to, fromJson).

4. Stream-based event system

Problem: Callback-based event systems create coupling and make cleanup error-prone.

Solution: Four typed streams (messageStream, eventStream, sceneStream, lifecycleStream) with automatic cleanup on dispose.

5. Reflection-based Unity player creation

Problem: Unity 6 changed the player class name and constructor signatures. Direct compilation dependency breaks across Unity versions.

Solution: The Android native layer uses reflection to try UnityPlayerForActivityOrService (Unity 6) first, then falls back to UnityPlayer (legacy). Multiple constructor signatures are attempted.

6. Message batching and throttling

Problem: Rapid message sending (e.g. position updates every frame) floods the native bridge and causes frame drops.

Solution: Optional MessageBatcher (coalesces by key, flushes per frame) and MessageThrottler (rate-limits with configurable strategy: drop, keepLatest, keepFirst).

vs flutter_unity_widget

Aspectflutter_unity_widgetunity_kit
Controller lifetimeTied to widgetIndependent bridge
Message typesRaw stringsTyped UnityMessage
Lifecycle managementNoneState machine with guards
Readiness guardNoneQueue + auto-flush
Message batchingNoneBatcher + Throttler
Asset streamingNoneFull pipeline with caching
Exception handlingSwallowed errorsTyped exception hierarchy
Unity 6 supportNot supportedReflection with fallback
Tests2 files28+ test files

Anti-patterns avoided from flutter_unity_widget

  • Controller tied to widget lifecycle.
  • Raw string messages without structure.
  • Swallowed errors (catch(e) { /* todo */ }).
  • Lack of resource cleanup.
  • Lack of readiness checking before sends.
  • Lack of message rate limiting.

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