Multi-Platform Abstraction in a Shared Unity Library

Intended Audience: Advanced Unity Developers

Estimated Reading Time: 6 minutes

The Problem

I run a small indie studio with multiple Unity projects: an action game set in ancient Egypt, and a casual mobile game, and other works in progress. All target multiple platforms (Windows Steam, Android, iOS, Mac). All three share common infrastructure: telemetry, UI utilities, platform-specific initialization, music middleware, animation utilities, and more.

The naive approach is to copy shared code between projects. This leads to the obvious problem: fix a bug in one project, forget to propagate it to the others. Or worse, propagate it incorrectly and introduce new bugs.

The better approach is a shared library. Unity supports this via local packages. My shared library is called MetroplexWebUnityLib (MWUL), and all client projects reference it.

But shared libraries introduce their own problem: platform-specific code (Steam integration on Windows, haptic feedback on Android) needs to compile conditionally. This post covers how I solved that without using preprocessor directives.

Platform-Specific Code Without Preprocessor Directives

The traditional Unity approach to platform-specific code looks like this:

void InitializePlatform()
{
#if UNITY_ANDROID
    Handheld.Vibrate();
#elif UNITY_STANDALONE_WIN
    SteamAPI.Init();
#else
    // fallback
#endif
}

This works, but it has problems:

I wanted clean separation: each platform's code in its own folder, compiled only when targeting that platform, with no preprocessor directives in the shared runtime code.

Assembly Definitions as a Conditional Compilation Mechanism

Unity's assembly definition system (.asmdef files) supports includePlatforms and excludePlatforms fields. An assembly with includePlatforms: [Android] only compiles when the build target is Android. This is the foundation of the solution.

The architecture has three layers:

Layer 1: Platform Contracts (Pure C#)

A small assembly containing only:

This assembly has noEngineReferences: true, meaning it has no Unity dependencies. It compiles everywhere and can theoretically be unit tested outside Unity.

public interface IPlatform
{
    PlatformTypes TargetPlatform { get; }
    bool PlatformIsInitialized { get; }

    void Startup();
    void Shutdown();
    void PeriodicUpdate();

    void InitScreen();
    void InitCursor();
    void HapticBuzz();
}

Layer 2: Platform Implementations

Each platform has its own folder with its own assembly definition:

PlatformAssemblies/
    _PlatformContracts/     # Layer 1
    _PlatformFallback/      # Default no-op implementation
    Android/                # Android-specific
    WindowsSteam/           # Windows with Steam integration

Each platform folder contains a PlatformImplementation.cs that implements IPlatform:

Android/PlatformImplementation.cs:

public class PlatformImplementation : PlatformBase
{
    public override PlatformTypes TargetPlatform
        => PlatformTypes.Android;

    public override void HapticBuzz()
        => Handheld.Vibrate();
}

WindowsSteam/PlatformImplementation.cs:

public class PlatformImplementation : PlatformBase
{
    public override PlatformTypes TargetPlatform
        => PlatformTypes.WindowsSteam;

    public override void Startup()
    {
        SteamAPI.Init();
        // ...
    }
}

The assembly definitions control which one compiles:

Android/zAndroidAssembly.asmdef:

{
  "name": "AndroidAssembly",
  "includePlatforms": ["Android"],
  "references": ["PlatformContracts", "MetroplexWeb"]
}

WindowsSteam/zWindowsSteamAssembly.asmdef:

{
  "name": "WindowsSteamAssembly",
  "includePlatforms": ["WindowsStandalone64"],
  "references": ["PlatformContracts", "MetroplexWeb", "Steamworks.NET"]
}

_PlatformFallback/zPlatformFallbackAssembly.asmdef:

{
  "name": "PlatformFallbackAssembly",
  "excludePlatforms": ["Android", "WindowsStandalone64"],
  "references": ["PlatformContracts", "MetroplexWeb"]
}

The fallback uses excludePlatforms to yield to any platform-specific assembly that claims a target. All unclaimed platforms (iOS, Mac, WebGL, Editor) get the fallback.

Layer 3: Auto-Registration

The final piece is automatic registration. Each platform implementation includes:

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void AutoRegister()
    => PlatformManager.Register(new PlatformImplementation());

This runs before any scene loads. The PlatformManager (in the main MWUL runtime assembly) receives the registration and exposes it to client code:

public static class PlatformManager
{
    static IPlatform m_platform;

    public static void Register(IPlatform platform)
    {
        m_platform = platform;
    }

    public static IPlatform Platform => m_platform;
}

Client code simply calls:

PlatformManager.Platform?.HapticBuzz();

The client does not know or care which implementation it got. Unity resolved that at compile time.

Why This Design Works

All platform assemblies have autoReferenced: false. Client projects never see them and never reference them directly. Clients only reference the main MetroplexWeb runtime assembly.

When a client project adds a new platform build target (say, Android), they do not need to:

The system is self-contained within the shared library. This is the same principle behind Unity's own package system: consumers should not need to understand internal implementation details.

Lessons

Assembly definitions are powerful but invisible. When the system works, you forget it exists. When it fails, the failure mode is often indirect. "Type not found" can mean many things, and the actual cause may be far removed from what the error suggests.

Document the "no action required" guarantees. The platform assembly system is designed so client projects need no changes when adding platform targets. But if that guarantee is not documented clearly, future-you will waste time investigating whether the client needs updates. Write it down.

Current State

The architecture now supports:

Adding a new platform requires:

  1. Create a folder with PlatformImplementation.cs
  2. Create an asmdef with appropriate includePlatforms
  3. Add auto-registration
  4. Update the fallback's excludePlatforms

Client projects require no changes.

Back to Home