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:
- Preprocessor blocks are hard to read and harder to refactor
- IDE tooling often breaks inside conditional blocks
- The logic for each platform is interleaved, not separated
- Testing any single platform path requires changing build targets
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:
IPlatforminterface defining what all platforms must implementPlatformBaseabstract class with shared functionalityPlatformTypesenum listing supported platformsPlatformMessagedata class for cross-assembly logging
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:
- Add assembly references to platform-specific assemblies
- Update any asmdef files
- Add conditional compilation symbols
- Reference platform dependencies like Steamworks.NET
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:
- Windows (with Steam integration)
- Android (with haptic feedback)
- Fallback (Editor, iOS, Mac, WebGL, anything not explicitly claimed)
Adding a new platform requires:
- Create a folder with
PlatformImplementation.cs - Create an asmdef with appropriate
includePlatforms - Add auto-registration
- Update the fallback's
excludePlatforms
Client projects require no changes.