Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
372450e
Move Unreal Android library mode into UPL
juicycleff Sep 4, 2026
13e80a3
Implement the Unreal bridge for iOS and macOS
juicycleff Sep 4, 2026
bed10f6
Build Unreal as a linkable dylib on Apple, and fix what that surfaced
juicycleff Sep 4, 2026
30a3fa7
Drive the Unreal engine tick from the host
juicycleff Sep 4, 2026
4c04381
Render surface for iOS, and what a real device run taught us about em…
juicycleff Sep 4, 2026
4f6b113
Fix what a source engine build and a real cook exposed
juicycleff Sep 4, 2026
6a7e6dd
Unreal renders inside a host app on iOS
juicycleff Sep 5, 2026
ce806b5
feat(unreal/ios): drive the engine from the Flutter platform view
juicycleff Sep 5, 2026
f3b3135
feat(unreal/ios): let host apps subclass IOSAppDelegate directly
juicycleff Sep 5, 2026
f88de24
fix(unreal): deliver messages from Flutter to the actors waiting for …
juicycleff Sep 6, 2026
9719f3f
feat(example): build the Unreal demo scene in code
juicycleff Sep 6, 2026
3f134d7
fix(unreal): route wildcard handlers, which is how every actor registers
juicycleff Sep 6, 2026
10d6668
fix(unreal): run messages from Flutter on the game thread, and stream…
juicycleff Sep 6, 2026
e8db970
feat(unreal): receive every message from Flutter without naming a target
juicycleff Sep 6, 2026
16da5ba
chore(example): let the CLI own the Podfile copy phase
juicycleff Sep 6, 2026
d36c359
fix(example): report the render state only once there is one
juicycleff Sep 6, 2026
907f2ef
fix(example): frame the camera once the viewport has a size
juicycleff Sep 6, 2026
ed3584c
test(example): prove the unified handler receives what the router cannot
juicycleff Sep 6, 2026
01e9c4a
feat(unreal): pause the game for real, and let a host unload the engine
juicycleff Sep 6, 2026
08d21c5
feat(unreal/macos): give macOS an embedded path, which the engine has…
juicycleff Sep 7, 2026
1c3aae6
feat(macos): build the macOS half of the framework
juicycleff Sep 7, 2026
dae79de
chore(macos): wire the podspec and enable macOS in the example config
juicycleff Sep 7, 2026
e3b5d8f
docs(unreal): leave a note for whoever picks this up next
juicycleff Sep 7, 2026
11431ba
fix(unity/macos): make it compile, so both engines build together
juicycleff Sep 7, 2026
a7344e7
chore: untrack the Unity widget assessment
juicycleff Sep 7, 2026
ca09e72
fix(unreal/android): start the engine through ASIS, not reflection
juicycleff Oct 22, 2026
6382ce3
fix(unreal/macos): compile the embedded view only into embedded builds
juicycleff Oct 22, 2026
ff62c76
feat(unreal/android): turn on ASIS in the demo project config
juicycleff Oct 22, 2026
700a8ce
docs(unity): use the framework's real name in the license line
juicycleff Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ build/
# ============================================================================
# Unreal Engine Integration
# ============================================================================
# These need the extra ** because an Unreal project usually sits one level
# down, as unreal/<ProjectName>/, and plugins nest their own build output again.
**/unreal/**/Binaries/
**/unreal/**/Build/
**/unreal/**/Intermediate/
**/unreal/**/Saved/
**/unreal/**/DerivedDataCache/
**/unreal/Binaries/
**/unreal/Build/
**/unreal/Intermediate/
Expand Down Expand Up @@ -281,3 +288,13 @@ app.*.symbols

# Keep empty directories with .gitkeep
!**/.gitkeep

# Unreal export outputs. Cooked content is large and rebuilt by
# "game export unreal", so it does not belong in the repository.
engines/unreal/dart/ios/UnrealContent/
engines/unreal/dart/macos/UnrealContent/
example/unreal/demo_exports/

# Content synced into the example app by "game sync unreal", rebuilt on demand.
example/macos/UnrealContent/
example/ios/UnrealContent/
47 changes: 38 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Game Framework provides a consistent API for integrating game engines into Flutt
- **Modular Architecture** - Use only the engines you need
- **Bidirectional Communication** - Flutter ↔ Engine messaging with type safety
- **Lifecycle Management** - Automatic pause/resume/destroy handling
- **Multi-Platform** - Android, iOS, macOS, Windows, Linux support
- **Multi-Platform** - Android, iOS and web today, with macOS in progress
- **Production Ready** - Comprehensive testing and documentation

## Monorepo Structure
Expand Down Expand Up @@ -256,14 +256,43 @@ Game Engine (Unity/Unreal)

## Platform Support

| Platform | gameframework | Unity | Unreal | Status |
|----------|--------------|-------|--------|--------|
| Android | ✅ Ready | ✅ Ready | 🚧 WIP | Stable |
| iOS | ✅ Ready | ✅ Ready | 🚧 WIP | Stable |
| Web | ✅ Ready | ✅ Ready | ⏳ Planned | Stable |
| macOS | ✅ Ready | 🚧 WIP | ⏳ Planned | Beta |
| Windows | ✅ Ready | 🚧 WIP | ⏳ Planned | Beta |
| Linux | ✅ Ready | 🚧 WIP | ⏳ Planned | Beta |
| Platform | Unity | Unreal |
|----------|-------|--------|
| Android | Working | Built, not yet run on a device |
| iOS | Working | Working, verified on device |
| Web | Working | Not started |
| macOS | Builds; not run | Starts and reaches Metal, does not render yet |
| Windows | Stub | Stub |
| Linux | Stub | Stub |

"Stub" means the platform directory holds a CMake file and an empty plugin
entry point. There is no controller and no platform view, so a `GameWidget`
there renders nothing. Web is Unity only; Unreal has no web target.

### Unreal on iOS

Working end to end, three commands and no hand editing:

```bash
game export unreal -p ios && game sync unreal -p ios && flutter build ios
```

Rendering, touch, messaging both ways, pause, and unload. Needs an engine
built from source: an installed engine ships its modules prebuilt, so
`BUILD_EMBEDDED_APP` never reaches them and the framework links, launches and
never boots an engine.

### Unreal on macOS

Further than the table suggests, and not finished. Unreal has no embedded mode
for Mac at all, so the target defines `BUILD_EMBEDDED_APP` itself and the
plugin supplies the startup and view path the engine provides only for iOS.
The app builds, the framework loads, the engine starts, opens the project and
initialises Metal. It then stops because nothing has been cooked for Mac and a
non-editor build cannot compile shaders at runtime.

See `engines/unreal/CONTINUE.md` for what is left and the constraints worth
knowing before changing any of it.

## Continuous Integration

Expand Down
9 changes: 7 additions & 2 deletions engines/unity/dart/ios/gameframework_unity.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ to sync your Unity export to your plugin's ios/ directory.
unity_framework_path = File.join(__dir__, 'UnityFramework.framework')
if File.exist?(unity_framework_path) || File.symlink?(unity_framework_path)
s.preserve_paths = 'UnityFramework.framework', 'UnityFramework.framework/Data'
# Don't vendor - let the consumer plugin vendor it to avoid conflicts
# s.ios.vendored_frameworks = 'UnityFramework.framework'

# Vendor it when it is sitting right here, which is the case after
# "game sync unity -p ios" with no separate game plugin in between. Without
# this nothing embeds the framework and the Swift compiler cannot find the
# module. A consumer plugin that vendors its own build syncs there instead,
# so this stays false for them and there is no duplicate.
s.ios.vendored_frameworks = 'UnityFramework.framework'
end

# Configure framework search paths to find UnityFramework from sibling pods
Expand Down
62 changes: 53 additions & 9 deletions engines/unity/dart/macos/Classes/UnityEngineController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,36 @@ import Cocoa
* - Proper NSView lifecycle management for embedding Unity in Flutter
* - Error handling with descriptive error events
*/
public class UnityEngineController: NSObject, FlutterPlatformView {
/// The part of UnityFramework this controller uses.
///
/// The type itself is not available when building: UnityFramework.framework is
/// assembled per game and loaded from the app bundle at runtime, so naming the
/// class here would make the plugin impossible to compile without a Unity build
/// on hand. The instance arrives through the bundle's principal class and is
/// messaged through this, which is what Objective-C was doing anyway.
///
/// Keep the selectors exact. A mismatch here compiles and then fails as an
/// unrecognised selector at runtime, which is a much worse place to find out.
@objc protocol UnityFrameworkInterface {
@objc func setDataBundleId(_ bundleId: String)

@objc func runEmbedded(withArgc argc: Int32,
argv: UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>?,
appLaunchOpts: [AnyHashable: Any]?)

@objc func appController() -> NSViewController?

@objc func pause(_ paused: Bool)

@objc func sendMessageToGO(withName name: String,
functionName: String,
message: String)

@objc optional func unloadApplication()
@objc optional func quitApplication(_ exitCode: Int32)
}

public class UnityEngineController: NSObject {

// MARK: - Static Active Controller Tracking

Expand Down Expand Up @@ -198,7 +227,12 @@ public class UnityEngineController: NSObject, FlutterPlatformView {
}

// Register the Unity framework with the FlutterBridgeRegistry
FlutterBridgeRegistry.register(unityFramework: unityFramework)
// The registry stores it as an NSObject, which every Unity
// framework instance is; the protocol is only how this file talks
// to it.
if let asObject = unityFramework as? NSObject {
FlutterBridgeRegistry.register(unityFramework: asObject)
}

// Set up Unity framework
unityFramework.setDataBundleId("com.unity3d.framework")
Expand All @@ -211,8 +245,7 @@ public class UnityEngineController: NSObject, FlutterPlatformView {
)

// Get Unity's root view
if let appController = unityFramework.appController(),
let rootView = appController.rootViewController?.view {
if let rootView = unityFramework.appController()?.view {
self.unityView = rootView

// Embed Unity view in our container
Expand Down Expand Up @@ -325,7 +358,9 @@ public class UnityEngineController: NSObject, FlutterPlatformView {
// MARK: - Unity Message Handling (called from C bridge)

/// Called from Unity when a message is sent to Flutter
@objc public func onUnityMessage(target: String, method: String, data: String) {
/// Swift-side convenience. Deliberately not @objc: it would carry the same
/// selector as the method below, and two of those on one class is an error.
public func onUnityMessage(target: String, method: String, data: String) {
onUnityMessageWithTarget(target, method: method, data: data)
}

Expand Down Expand Up @@ -400,9 +435,9 @@ public class UnityEngineController: NSObject, FlutterPlatformView {
// - Data/ = game data
// Pre-load GameAssembly.dylib so UnityPlayer can resolve IL2CPP symbols when the bundle loads.

private func loadUnityFramework() -> UnityFramework? {
private func loadUnityFramework() -> UnityFrameworkInterface? {
// Try to get from cache first
if let cached = FlutterBridgeRegistry.sharedUnityFramework as? UnityFramework {
if let cached = FlutterBridgeRegistry.sharedUnityFramework as? UnityFrameworkInterface {
return cached
}

Expand Down Expand Up @@ -431,8 +466,17 @@ public class UnityEngineController: NSObject, FlutterPlatformView {
return nil
}

let getInstance = principalClass.getInstance()
return getInstance as? UnityFramework
// getInstance is Unity's own class method, so it has to be sent
// dynamically too rather than called on a type nothing here declares.
let selector = NSSelectorFromString("getInstance")
guard let unityClass = principalClass as? NSObject.Type,
unityClass.responds(to: selector) else {
NSLog("UnityEngineController [macOS]: principal class has no getInstance")
return nil
}

let instance = unityClass.perform(selector)?.takeUnretainedValue()
return instance as? UnityFrameworkInterface
}

// MARK: - Cleanup
Expand Down
85 changes: 85 additions & 0 deletions engines/unreal/CONTINUE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Where the Unreal work stands

Written 2026-09-07, at the end of a long session. Everything here was seen
working or seen failing on this machine, not inferred.

## iOS: working, verified on device

Unreal renders inside a `GameWidget` on an iPhone 16 Pro at full native
portrait resolution. Drag orbits, pinch zooms, the HUD controls drive the
cube, and camera state streams back. Pause freezes the scene, unload releases
the view and reload brings it back mid-scene.

Three commands, no hand editing:

game export unreal -p ios && game sync unreal -p ios && flutter build ios

`flutter run` times out installing 565MB over wireless. USB is fine.

## macOS: builds, starts, stops at shaders

Further than it looks, and not finished. The app builds, the framework loads,
the engine starts, reads its command line, opens the project and initialises
Metal. It then fails:

LogShaderLibrary: Error: Failed to initialize ShaderCodeLibrary ...
part of the Global shader library is missing

Nothing has been cooked for Mac, and a non-editor build cannot compile shaders
at runtime.

**Next step, and it is a long one.** Build the Mac editor from source, cook the
project for Mac, then re-export. The engine build alone took 78 minutes for the
game target; the editor is bigger. After that the framework should have what it
needs, and the next unknown is whether reparenting the engine's `FCocoaWindow`
content view into the Flutter platform view actually renders. That has never
run, so treat it as unproven rather than merely untested.

Two things a macOS host needs, which the export prints but nothing enforces:

- The app must not be sandboxed for an uncooked run, because it reads the
project from a path outside its container. Cooked content in the bundle
removes this.
- The engine finds its own content relative to the executable, so it needs
`-basedir=<engine>/Engine/Binaries/Mac`. That is read from the process argv,
not from `uecommandline.txt`, so it cannot be set from inside the library.
A staged layout beside the app would avoid it.

## Android

The UPL migration is in and the library-mode Java is injected at build time.
Not run on a device in this session, so treat it as built but unverified.

## Things worth knowing before changing anything

- `BUILD_EMBEDDED_APP` is defined only by `UEBuildIOS.cs`. The Mac target
defines it itself and takes `TargetBuildEnvironment.Unique` so it reaches
Core, which is why the Mac build rebuilds the engine.
- The host's tick is a display link on the **main thread**, not Unreal's game
loop. `TickGameThread` drains its queue on whoever calls it, so anything
touching the renderer must not run there. `FTSTicker` is the way onto the
real game thread; `RunOnGameThread` is not.
- Unreal's log file is buffered and mostly shows startup. Do not conclude
anything from a missing runtime line. Route diagnostics back over the bridge
instead; `flutter.TraceMessages 1` turns the message trace on.
- A flat iOS framework must not contain `Resources/`. A versioned macOS one
must carry `Versions/A/Resources/Info.plist`. These are opposite rules and
both are enforced by tooling that blames something else.

## Unity's macOS controller

It did not compile, which broke the macOS build of any app depending on both
engines. Fixed to the point where both build together, and no further: Unity
could not be run here, so treat macOS Unity as compiling rather than working.

What was wrong: it conformed to `FlutterPlatformView`, which does not exist on
macOS and which its factory never needed, since that already returns an
`NSView`. It named the `UnityFramework` type, which is not available when
building because the framework is assembled per game and loaded from the
bundle at runtime. And it declared two methods carrying the same Objective-C
selector.

The framework is reached through an `@objc protocol` now, so the selectors are
declared in one place. Get one wrong and it compiles and then fails at runtime
as an unrecognised selector, so they are worth checking against Unity's own
header before trusting them.
8 changes: 7 additions & 1 deletion engines/unreal/dart/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ android {
}

defaultConfig {
minSdkVersion 21
// Unreal compiles its Android binary against NDK API 26 ("Compiling
// Native 64-bit code with NDK API 'android-26'"), so a host that
// declares less than that can install on a device where libUnreal.so
// cannot load. Declaring it here rather than in the app means the
// manifest merger raises any host that asks for less, and the host does
// not have to know why.
minSdkVersion 26
targetSdkVersion 33
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.epicgames.asis;

/**
* The Java half of Unreal's ASIS entry point, which the engine expects to find
* and this engine tree does not ship.
*
* Turning on bEnableASISPlugin gets you USE_ANDROID_STANDALONE, and with it a
* set of JNI entry points declared in Core's AndroidJniGameActivity.h as
* UE::Jni::Asis::FAsisGameActivity. Those are registered rather than exported:
* the engine walks a UE_Jni_Initialize linker section at startup, looks each
* type's ClassName up with FindClass, and calls RegisterNatives against it.
*
* Two consequences worth knowing before you touch this file.
*
* The class name is a string in the engine binary, "com/epicgames/asis/AsisGameActivity",
* so the package and name here are load-bearing. Rename either and the lookup
* fails.
*
* And a failed lookup is not survivable. FEnv::Initialize guards it with a
* checkf, and FAsisGameActivity does not opt out through bIsOptional the way an
* optional binding would, so an APK that enables ASIS without shipping this
* class asserts during JNI init rather than falling back to anything.
*
* The methods are static because the native side declares each one taking a
* jclass rather than a jobject. Signatures have to match exactly; RegisterNatives
* reports a mismatch by returning negative, which the engine turns into a
* cleared exception and a false, so a wrong signature here surfaces later as an
* UnsatisfiedLinkError at the call rather than as an error at startup.
*
* Nothing extends com.epicgames.unreal.GameActivity on purpose. The C++ wrapper
* FAsisGameActivity does derive from FGameActivity, but that is a C++ type
* hierarchy for member access, and RegisterNatives only cares about names and
* signatures on the class it is handed. Keeping this standalone means the plugin
* compiles without Unreal's generated Java on the source path.
*/
public class AsisGameActivity {

/**
* Starts the engine. Runs UE's main on the calling thread and does not
* return while the engine lives, so call it from a thread you are willing
* to give up.
*
* @param projectModule name of the game module to load, matching the
* project's module name rather than the .so name.
*/
public static native void nativeMain(String projectModule);

/**
* Sets the command line before nativeMain. Same role as uecommandline.txt
* on the Apple platforms, and like it this has to be set before the engine
* reads it rather than after.
*/
public static native void nativeSetCommandline(String commandline);

/**
* Feeds one APP_CMD_* value to the engine's event loop, which is how a host
* that is not a NativeActivity drives lifecycle and window events.
*
* The engine reads the window from GAndroidWindowOverride when it handles
* APP_CMD_INIT_WINDOW, so that global has to point at a live ANativeWindow
* before this is called with it.
*/
public static native void nativeAppCommand(int cmd);

/** Returns the OBB comment, unused when content is packaged inside the APK. */
public static native String nativeGetObbComment();

private AsisGameActivity() {
}
}
Loading
Loading