Typed Bridges Between Unity and Native ── C#, Kotlin, and Swift APIs From One Contract

Hello! I’m @apippo from the AnotherBall Mobile Team. We built a bridge code generator that keeps Unity, Android, and iOS APIs aligned using one C# contract.

In a previous post, Davide wrote about how our game development and native app work come together inside Avvy.

I also come from a game development background. Over the years, I have worked with Unity/C#, Unreal/C++, Kotlin, and Swift. Building a bridge between Unity, Android, and iOS felt like a natural problem for me to take on.

This article picks up one piece of that story and focuses on how Unity and native app code communicate without asking engineers to keep the C#, Kotlin, and Swift bridge layers in sync manually.

Why We Needed a Generator

Avvy embeds Unity inside native Android and iOS apps using Unity as a Library (UaaL). Unity provides an official UaaL example that demonstrates this integration on both platforms.

In Avvy, Unity handles avatar rendering and related features, while the rest of the app stays native. We explained how we manage that split on iOS in Embedding UaaL in a SwiftUI App: Managing the View Lifecycle.

That split works well for the app, but it creates a specific engineering problem. Unity, Android, and iOS need to talk to each other often, and the bridge API must stay in sync across C#, Kotlin, and Swift.

Native --> Unity Starts With Messages

UaaL already gives us a simple way for native code to call Unity.

On Android, native code can call a method on a Unity GameObject with UnitySendMessage:

1
2
3
4
5
6
// Kotlin
UnityPlayer.UnitySendMessage(
"AvatarRoot",
"SetExpression",
"happy",
)

On iOS, UnityFramework provides a matching message API:

1
2
3
4
5
6
// Swift
unityFramework.sendMessageToGO(
withName: "AvatarRoot",
functionName: "SetExpression",
message: "happy"
)

A Unity component receives the message through a matching method:

1
2
3
4
5
// C#
void SetExpression(string expression)
{
...
}

For a small bridge, this is enough. The native app sends the GameObject name, method name, and a single string payload.

Both message APIs use strings and are not type safe. The Kotlin and Swift compilers cannot tell us whether "AvatarRoot" exists, whether "SetExpression" still has the same name, or whether "happy" is a valid payload.

The target Unity GameObject has to be active when the message is sent, which means native code ends up depending on Unity scene state. If the payload changes from a single string to structured avatar state, Unity, Android, and iOS need to agree on the serialization format by convention.

Native <-- Unity Needs Platform Glue

The Native <-- Unity direction requires platform-specific glue. On Android, Unity can call into native code through AndroidJavaClass:

1
2
3
4
5
6
7
// C# for Android
#if UNITY_ANDROID
using (var bridge = new AndroidJavaClass("com.example.NativeAvatarBridge"))
{
bridge.CallStatic("openAvatarPicker", selectedAvatarId);
}
#endif

The Kotlin side then needs to expose a matching static entry point:

1
2
3
4
5
6
7
// Kotlin
object NativeAvatarBridge {
@JvmStatic
fun openAvatarPicker(selectedAvatarId: String) {
...
}
}

On iOS, the same Native <-- Unity call needs a native plugin function:

1
2
3
4
5
// C# for iOS
#if UNITY_IOS
[DllImport("__Internal")]
private static extern void openAvatarPicker(string selectedAvatarId);
#endif

The Swift side needs to expose the exact symbol that Unity imports:

1
2
3
4
5
6
// Swift
@_cdecl("openAvatarPicker")
func openAvatarPicker(_ selectedAvatarIdPointer: UnsafePointer<CChar>) {
let selectedAvatarId = String(cString: selectedAvatarIdPointer)
...
}

Return Values Turn Calls Into Request Flows

With these one-way message APIs, a return value requires another call in the opposite direction.

For Native --> Unity, imagine native code calling an API like this.

1
2
// Kotlin
val state = unityBridge.getAvatarState()

With UnitySendMessage or UnityFramework’s message API, native cannot get that value directly. Those APIs only send a message into Unity. One common manual implementation turns it into a request flow:

  1. Native creates a request ID.
  2. Native stores the pending callback for that request ID.
  3. Native sends the request ID to Unity.
  4. Unity calculates the value.
  5. Unity calls native back with the same request ID and the result.
  6. Native finds the pending callback and resumes the original call.

The native side starts looking like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Kotlin, simplified pseudocode
suspend fun getAvatarState(): AvatarState =
suspendCancellableCoroutine { continuation ->
// Keep the suspended Kotlin call so it can be resumed later.
val requestId = pendingAvatarStateRequests.store(continuation)

// Send the request ID to Unity. This does not return AvatarState.
UnityPlayer.UnitySendMessage(
"AvatarRoot",
"GetAvatarState",
requestId,
)

// Remove the pending request if the caller is cancelled.
continuation.invokeOnCancellation {
pendingAvatarStateRequests.remove(requestId)
}
}

// Called by Unity after it finishes building the result.
fun onAvatarStateResult(requestId: String, payload: String) {
// Look up the original Kotlin call by request ID.
val continuation = pendingAvatarStateRequests.remove(requestId) ?: return

// Now the original getAvatarState() call can finally return.
continuation.resume(decodeAvatarState(payload))
}

This example leaves out timeout handling, decoding errors, duplicate responses, and Unity teardown. Those are more pieces of plumbing that a production implementation must handle.

Unity then needs a matching response path:

1
2
3
4
5
6
7
8
9
10
// C#
// Called by native through UnitySendMessage.
void GetAvatarState(string requestId)
{
// Build the value native originally asked for.
var payload = avatarController.ExportStateJson();

// Call native back with the same request ID to complete the request.
NativeAvatarBridge.OnAvatarStateResult(requestId, payload);
}

The same request and response machinery is needed for Native <-- Unity when Unity needs a value from native.

1
2
3
// C#
var selectedAvatarId = await NativeAvatarBridge.OpenAvatarPicker(currentAvatarId);
avatarController.ApplyAvatar(selectedAvatarId);

OpenAvatarPicker returns UniTask<string>, the async task type we use in Unity. Opening a native picker does not finish immediately.

Native has to show UI, wait for the user, and send the selected value back later. Unity also needs a pending task source, cancellation handling, and a response method. Depending on the concurrency rules, correlation can use request IDs or one pending completion per method.

At that point, the bridge is no longer just a message call. We are building infrastructure similar to a remote procedure call (RPC) system, with request IDs, pending calls, serialized responses, and completion callbacks.

So the problem is not just passing a value across the boundary. The real cost is building the request and response machinery around every bridge call that needs a result.

The Cost Grows With App Features

This manual plumbing is manageable for one method. It becomes expensive when the bridge is part of the app architecture.

In Avvy, the bridge is used for things like:

  • Switching Unity views from native screens
  • Sending stream layout information to Unity
  • Asking Unity for avatar state before opening a live stream
  • Letting Unity open native modals
  • Updating native UI when Unity interactions happen

At this scale, manual bridge code creates several problems:

  • Method names and object names are plain strings.
  • Naming has to stay consistent across C#, Kotlin, and Swift.
  • Android and iOS need separate native glue for the same Unity function.
  • Structured payloads, callbacks, and async responses need custom plumbing every time.

Without code generation, reviewers would also have to ask questions like these:

  • Does the Kotlin method name match the C# method name?
  • Does Swift encode the same payload fields?
  • Does Kotlin resume the right pending callback?
  • Does Swift handle the async response path the same way?

Those are not the questions we want reviewers spending most of their time on.

We expected this plumbing to grow, so we invested in code generation from the beginning and made one rule: the bridge contract is defined once in Unity C#.

C# as the Contract

Our bridge follows a code generation pattern common in backend systems. Protocol Buffers (.proto) and OpenAPI (.yml) use schema files as input. Our bridge uses C# definitions in .cs files instead.

These annotated C# definitions are the Single Source of Truth. Our custom [NativeCodegen] attribute marks the interfaces and data types that belong to the bridge contract, including data transfer objects (DTOs) and enums.

The generator has two entry points. During C# compilation, a Roslyn source generator reads the annotated definitions and generates the C# bridge implementation. A CLI uses the same contract model to generate Kotlin, Swift, and platform plugin files. Because both entry points share one codebase and contract model, there is no separate schema project to keep in sync.

Engineers maintain the C# contract once, then write the app-specific behavior in Unity, Android, and iOS. Generated code owns the repeated transport plumbing between the platforms.

Start with a data type used by the bridge. Primitive values stay primitive, while structured payloads are defined as DTOs.

1
2
3
4
5
6
7
8
9
// C#
[NativeCodegen]
[Serializable]
public class AvatarState
{
public string avatarId;
public string expression;
public bool isVisible;
}

The native bridge generator produces matching Kotlin and Swift models from the same AvatarState contract.

Kotlin:

1
2
3
4
5
6
7
// Generated Kotlin
@Serializable
data class AvatarState(
val avatarId: String,
val expression: String,
val isVisible: Boolean,
)

Swift:

1
2
3
4
5
6
7
8
9
10
11
12
// Generated Swift
public final class AvatarState: Codable, Sendable {
public let avatarId: String
public let expression: String
public let isVisible: Bool

public init(avatarId: String, expression: String, isVisible: Bool) {
self.avatarId = avatarId
self.expression = expression
self.isVisible = isVisible
}
}

After running the native bridge generator, the same field addition or rename is reflected in the generated Kotlin and Swift diffs.

Generate Plumbing, Write App Behavior

Together, the source generator and native bridge generator handle repeated transport work while engineers stay focused on the contract and app behavior.

The diagram separates the code engineers write from the bridge code generated from it. The orange files contain the C# contract and each platform’s app behavior. The blue files carry calls and callbacks between Unity, Android, and iOS.

Our workflow has four steps.

  1. Define or update the C# contract
  2. Run the native bridge generator
  3. Review the generated files as the result of that contract change
  4. Implement the handwritten Unity, Android, and iOS behavior

Generating the repeated transport layer reduces implementation and review work and leaves fewer places for definitions to drift. Review stays focused on the contract and each platform’s behavior.

C# Signatures Define the Direction

We wanted the contract to be readable without adding a separate schema language. The direction comes from the C# member shape.

Native <-- Unity

Unity starts the call. Kotlin and Swift handle it through callbacks registered with the generated APIs.

  • No return value
    • C# member: void Method(...)
    • Generated API: callback registration
  • Native returns R
    • C# member: UniTask<R> Method(...)
    • Generated API: registration for an async callback that returns R

Native --> Unity

Native starts the call through a generated Kotlin or Swift method.

  • No return value
    • C# member
      • Action Event { get; set; }
      • Action<T> Event { get; set; }
    • Generated API: method that invokes the Unity callback
  • Unity returns R
    • C# member
      • Func<UniTask<R>> Callback { get; set; }
      • Func<T, UniTask<R>> Callback { get; set; }
    • Generated API: async method returning R

Together, these shapes give the generation toolchain enough information to validate the contract without introducing another schema file.

The IAvatarBridge contract below includes one member for each case.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// C#
[NativeCodegen]
public interface IAvatarBridge
{
// Native <-- Unity. No return value.
void CloseAvatarPicker();

// Native <-- Unity. Native returns a value.
UniTask<string> OpenAvatarPicker(string currentAvatarId);

// Native --> Unity. No return value.
Action<string> SetAvatarExpression { get; set; }

// Native --> Unity. Unity returns a value.
Func<UniTask<AvatarState>> GetAvatarState { get; set; }
}

From that one C# interface, Kotlin gets callback properties for calls started by Unity and functions for calls started by native code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Generated Kotlin
interface IAvatarBridge {
// Native <-- Unity. No return value.
var onCloseAvatarPicker: (suspend () -> Unit)?

// Native <-- Unity. Native returns the selected avatar ID.
var onOpenAvatarPicker: (suspend (String) -> String)?

// Native --> Unity. No return value.
suspend fun setAvatarExpression(expression: String)

// Native --> Unity. Generated Kotlin returns AvatarState?.
suspend fun getAvatarState(): AvatarState?
}

Swift gets the same bridge shape through a generated Actor protocol:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Generated Swift
public protocol AvatarBridgeProtocol: Actor {
// Native <-- Unity. No return value.
func registerCloseAvatarPickerCallback(
_ callback: @escaping (@Sendable () async -> Void)) async

// Native <-- Unity. Native returns the selected avatar ID.
func registerOpenAvatarPickerCallback(
_ callback: @escaping (@Sendable (String) async -> String)) async

// Native --> Unity. No return value.
func setAvatarExpression(_ expression: String) async

// Native --> Unity. Unity returns AvatarState.
func getAvatarState() async -> AvatarState
}

A Function Call Becomes a Callback

The diagram below uses Android as an example of how a generated API changes form across the bridge. For Native --> Unity, handwritten Android code calls a generated Kotlin function, and handwritten Unity code receives the call through a callback. Native <-- Unity follows the same flow in reverse. On iOS, the generated Swift bridge follows the same pattern.

Code generation keeps method signatures and payload types aligned across C#, Kotlin, and Swift. It cannot verify the bridge’s runtime state. When a call happens, Unity must be ready, callbacks must be registered, and native plugins must be loaded.

What’s Next

This article focused on bridge calls where type safety, async flow, and consistency across platforms matter most.

We also have another Unity bridge for face tracking data sent at high frequency. That path has different performance requirements and uses a completely different approach.

Instead of sending messages, native code and Unity agree on a block of memory and a binary layout. Native code writes the latest face tracking values into that memory, and Unity reads them directly every frame.

As someone with a game development background, this technique feels familiar and a little old school. I plan to cover that bridge in a follow-up.

We’re Hiring

AnotherBall is looking for engineers interested in app development using Kotlin/Swift/Unity/AI.
We’re seeking teammates to grow our product together while adopting new technologies like KMM and Compose Multiplatform.

AnotherBall Careers