DEV Community

GameDevToolLab
GameDevToolLab

Posted on

Why Use Precompiled C# DLLs in Unity? asmdef, Shared Server Code, and Practical Trade-offs

Introduction

In a typical Unity project, C# source files live under Assets, and the Unity Editor compiles them for you. For a small project, this is usually the simplest and most productive workflow.

As a project grows, however, different requirements begin to appear:

  • Share calculations and validation rules between a Unity client and a game server written in C#.
  • Test game logic quickly without starting Unity.
  • Remove mature, rarely changed infrastructure code from Unity's everyday recompilation loop.
  • Use newer C# syntax than the Unity Editor can compile directly.
  • Distribute the same library to multiple games.
  • Deliver a versioned binary internally or externally instead of distributing source code.

One possible answer is to compile selected C# code outside Unity and import the result as a managed DLL.

Unity's documentation describes managed plug-ins as .NET assemblies produced outside Unity with tools such as Visual Studio, MSBuild, or the .NET SDK. Normal scripts in the Unity project are recompiled by Unity when their source changes, while an imported DLL is treated as a precompiled asset.

This does not mean that moving every C# file into a DLL automatically makes a project faster, safer, or easier to maintain. DLLs are most valuable for code that changes relatively infrequently, has little or no dependency on Unity APIs, and is reused in more than one runtime or product.

This is Part 1 of a two-part series. It focuses on the benefits, architecture, and adoption criteria for external C# DLLs in Unity. In particular, it covers shared client/server code, compilation boundaries, standard .NET tests, dependency control, versioned distribution, and the trade-offs compared with Assembly Definition files.

Part 2 covers the implementation details: newer C# syntax, global.json, csproj, netstandard2.1, copying DLLs into Unity, Plugin Importer settings, .meta files, IL2CPP/AOT restrictions, and debugging symbols.

The examples are based on Unity 6.5 as of July 2026.

What This Part Covers

DLL-based workflows combine several different topics: architecture, compilation, language versions, Unity's Plugin Importer, IL2CPP, packaging, and deployment. Mixing all of them into one article makes it difficult to separate the adoption decision from the implementation details, so this guide is split into two parts.

This first part covers:

  • The difference between an external DLL and an asmdef.
  • Sharing code between a Unity client and a C# server.
  • The effect on Unity compilation time.
  • Running unit tests without launching Unity.
  • Using project boundaries to keep Unity dependencies out of core logic.
  • Designing a small public API.
  • Distributing a versioned library across multiple projects.
  • The disadvantages and operational costs of DLLs.

The Main Recommendation: Put Stable Core Code in a DLL

The best candidates for an external DLL are usually the following:

  • Damage, reward, stamina, and experience calculations.
  • Input and request validation.
  • IDs, enums, value objects, and protocol-level types.
  • State transitions and game progression logic that do not depend on Unity APIs.
  • General-purpose encryption, compression, transformation, and diff logic.
  • SDKs used by multiple games, servers, live-ops tools, or batch processes.
  • Mature infrastructure code that is no longer edited every day.

The following code is usually easier to keep as source inside the Unity project:

  • Gameplay code built around frequently edited MonoBehaviour classes.
  • Components tuned repeatedly through the Inspector.
  • Logic tightly coupled to scenes, prefabs, animations, or Timeline.
  • Code that changes several times a day while a feature is still being designed.
  • Editor extensions that must track Unity API changes closely.

A practical architecture is often a three-layer split rather than an attempt to move everything into a DLL:

Game.Shared.Core
  └ Shared logic with no UnityEngine dependency.
     Built as an external DLL.

Game.Unity
  └ MonoBehaviour, ScriptableObject, UI, input, and presentation.
     Calls Shared.Core.

Game.Server
  └ ASP.NET Core, Worker Service, or another server runtime.
     References Shared.Core.
Enter fullscreen mode Exit fullscreen mode

The value does not come from changing a file extension to .dll. It comes from establishing a Unity-independent core and reusing exactly the same rules across multiple execution environments.

DLLs and asmdef Files Solve Related but Different Problems

When Unity compilation time becomes a problem, the first tool to consider is usually an Assembly Definition file, or .asmdef.

An asmdef divides source code inside the Unity project into multiple assemblies. Unity can then recompile the changed assembly and the assemblies that depend on it, rather than rebuilding one enormous Assembly-CSharp.dll every time.

An external DLL also creates an assembly boundary, but its development workflow is different.

Area asmdef External DLL
Source location Inside the Unity project In an external .NET project
Compiler Unity's bundled Roslyn A selected .NET SDK/Roslyn version
Applying changes Unity recompiles automatically Rebuild the DLL, then update it in Unity
Referencing Unity APIs Straightforward Requires references to Unity assemblies
Newer C# syntax Limited by Unity's support Limited by the selected compiler, runtime, and API compatibility
Sharing with a server Possible, but requires extra structure Straightforward for a pure .NET library
Debugging Same as normal Unity scripts Requires symbol and source-version management
Frequently edited code Well suited Additional build/copy steps become costly
Binary distribution Not its main purpose Well suited

If compilation time is the only concern, start with asmdef files. They usually provide most of the benefit with less operational overhead.

External DLLs become more attractive when you also need one or more of the following:

  • A shared implementation for client, server, and tools.
  • A separately maintained build pipeline.
  • A compiler newer than the one Unity uses for project source.
  • Versioned binary distribution.
  • Strict separation from Unity APIs.

Updating a DLL still causes Unity assemblies that reference it to be recompiled. DLLs do not eliminate Unity compilation entirely. They are effective when a large, stable body of code is precompiled and Unity sees only a relatively small, stable public API.

Benefit 1: Share the Same Rules Between Unity and a C# Server

The most obvious benefit is sharing code with .NET applications outside Unity.

Suppose both the client and server contain this calculation:

public static int CalculateRequiredGold(int currentLevel)
{
    return 100 + currentLevel * currentLevel * 25;
}
Enter fullscreen mode Exit fullscreen mode

Copying it into both projects works initially, but sooner or later only one copy changes:

  • The server changes the coefficient after level 20.
  • The Unity client's prediction still uses the old formula.
  • The UI shows that an upgrade costs 1,000 gold.
  • The server API rejects the request because the real cost is 1,200 gold.

This type of inconsistency can be harder to discover than a networking failure. The API is returning a valid error, and the client may not throw an exception. The two sides simply disagree about the rules.

A shared library lets the Unity client, the server, internal tools, and tests reference the same implementation:

Shared.GameRules.dll
├── LevelRule
├── DamageFormula
├── RewardCalculator
├── ItemUseValidator
└── ProtocolConstants
Enter fullscreen mode Exit fullscreen mode

The server itself can still target a modern runtime such as .NET 10. Only the shared library needs to target a compatibility surface such as netstandard2.1, allowing both Unity and the server to reference it.

Code That Is Easy to Share

The best shared code has explicit inputs and outputs and does not access global engine state.

public readonly struct BattleParameter
{
    public BattleParameter(int attack, int defense, int skillPower)
    {
        Attack = attack;
        Defense = defense;
        SkillPower = skillPower;
    }

    public int Attack { get; }
    public int Defense { get; }
    public int SkillPower { get; }
}

public static class DamageFormula
{
    public static int Calculate(BattleParameter value)
    {
        var raw =
            (long)value.Attack * value.SkillPower / 100L
            - value.Defense;

        if (raw <= 1)
        {
            return 1;
        }

        return raw >= int.MaxValue
            ? int.MaxValue
            : (int)raw;
    }
}
Enter fullscreen mode Exit fullscreen mode

Using ordinary classes and readonly struct values at the public boundary tends to reduce friction with Unity's compiler and serializers. The important point is not the exact syntax. The important point is that the calculation does not depend on UnityEngine.Object, GameObject, Transform, Time, or any other engine-owned state.

The calculation also widens intermediate multiplication to long. The final value saturates to the range from 1 to int.MaxValue. In production code, the overflow policy should be part of the game specification: reject the input with checked, saturate, wrap intentionally, or use a larger numeric type.

Once the policy is implemented in the shared library, the Unity client, server, and tests all observe the same behavior.

Code That Should Not Be Shared

Using C# on both sides does not mean that every implementation belongs in a common DLL.

The following concerns are usually safer as server-only code:

  • Hidden loot probabilities and anti-cheat rules.
  • Database transactions.
  • Authentication, authorization, and signature verification.
  • Server-side inventory and billing decisions.
  • Secret keys and server-only configuration.
  • Logic whose authoritative input is the server clock or server-owned state.

The following concerns are usually Unity-specific:

  • Frame updates.
  • Object lifetime and scene ownership.
  • Presentation state.
  • Animation and VFX timing.
  • Inspector-driven authoring.

A shared ItemUseValidator on the client can reject an obviously invalid input before sending a request. This improves feedback and reduces unnecessary traffic. The server must still execute the authoritative validation again.

A client-side managed DLL can be decompiled. Never place secret values or security-sensitive server decisions in a shared client DLL.

The Same DLL Does Not Guarantee the Same Result

Sharing the implementation is only one part of deterministic behavior. Different inputs or execution assumptions still produce different results.

For client/server rules, define at least the following:

  • The rounding mode and when rounding occurs.
  • Whether a value uses float, double, decimal, or an integer/fixed-point representation.
  • Whether time is UTC and which clock is authoritative.
  • Whether only a random seed is shared or the random algorithm is also fixed.
  • Whether parsing and formatting may depend on CultureInfo.
  • Whether overflow throws, saturates, or wraps.

For currencies and rewards that must match exactly, storing the smallest unit as an integer is usually safer than using float. If decimal values are required, the Math.Round mode and the order of operations should also be specified.

Do not treat the device clock as authoritative. A client may use the shared library to display an estimate, but the server should recalculate the final result from server-managed UTC time.

Randomness has the same issue. UnityEngine.Random on the client and System.Random on the server are not guaranteed to produce the same sequence from the same seed. If results must match, either pass the random value into the rule, fix the algorithm inside the shared library, or let the server produce and return the final result.

A DLL gives you one implementation of the rule. Production consistency additionally requires the same inputs, rounding, time base, and random policy.

Sharing DTOs Requires Versioning Discipline

Sharing request and response DTOs can reduce mismatched names and types. It can also couple the release cadence of the client and server too tightly.

Long-running products should decide how contract evolution works:

  • Use separate DTOs per API version.
  • Treat additions as compatible and removals or type changes as breaking changes.
  • Make a schema such as Protobuf the source of truth and generate code for both sides.
  • Share domain values in the common DLL, but map them to HTTP DTOs separately in the client and server.

"We can share the type" is not the same as "we should use this exact type forever." The boundary should match the deployment and compatibility model.

Benefit 2: Reduce the Source Unity Must Compile in the Daily Loop

Unity compiles normal C# scripts when they change, while imported managed DLLs are treated as precompiled assemblies.

Consider a project with the following layout:

Assets/Scripts
├── GamePlay          800 files
├── UI                500 files
├── Network           300 files
├── Data              400 files
└── StableFramework  1200 files
Enter fullscreen mode Exit fullscreen mode

If StableFramework is mature and changes only once every few weeks, recompiling and reanalyzing all of its source during ordinary gameplay work may not provide much value.

Moving it to an external DLL means that Unity normally handles only its public API and compiled IL. Changes in the game project do not rebuild the library itself.

There are three configurations worth comparing:

  1. Put everything in Assembly-CSharp.
  2. Split Unity source into multiple assemblies with asmdef files.
  3. Move stable code into an external DLL.

For many projects, moving from the first configuration to the second produces most of the improvement. The third adds value when the library can genuinely operate on a release cycle independent of the Unity project.

What Time Can Be Reduced

An external DLL can reduce:

  • Unity compilation of the moved source set.
  • Unity-side Roslyn Analyzer execution for that source.
  • Source enumeration during Unity project generation.
  • IDE analysis of a very large Unity-generated project.
  • Recompiling the same library independently in several Unity games.

The following costs remain:

  • Building the external .NET project.
  • Copying and importing the DLL into Unity.
  • Recompiling Unity assemblies that depend on the updated DLL.
  • Assembly reload and static initialization.
  • Converting the managed DLL during an IL2CPP Player build.

Moving code that changes every few minutes into a DLL can make iteration slower because it adds dotnet build, copy, and Unity import steps.

The optimization is not "remove compilation." It is "remove stable code from the ordinary Unity edit-and-play loop."

Limit the Reference Graph or the Benefit Shrinks

By default, a custom asmdef can implicitly reference precompiled DLLs whose Plugin Importer setting Auto Referenced is enabled. Updating such a DLL may therefore recompile assemblies that do not actually use it.

To reduce the affected graph, disable Auto Referenced for the DLL and enable Override References only on asmdef files that need it.

Shared.GameRules.dll
        ↑
Game.Domain.asmdef
        ↑
Game.Presentation.asmdef
Enter fullscreen mode Exit fullscreen mode

Do not implicitly reference the library from unrelated UI, editor, or test assemblies.

The DLL boundary becomes valuable only when the reference graph is also kept small.

Common Misconception: A DLL Does Not Automatically Make Runtime Code Faster

"Precompiled DLL" can sound as if it executes faster than C# source stored in the Unity project. In practice, the main benefit is the development-time build boundary and reusability, not game runtime speed.

Unity project source is eventually compiled into managed assemblies as well. With IL2CPP, Unity performs managed stripping across project assemblies and external DLLs, converts IL to C++, and produces native code. If the logic is the same, its origin as project source or a prebuilt DLL does not by itself create a meaningful runtime performance difference.

Overengineering the boundary can even add costs:

  • Allocations when copying data into public DTOs.
  • Conversion between Unity-specific and shared types.
  • Extra virtual calls introduced only for abstraction.
  • Copies across DLL boundaries that exist only because of packaging.
  • Reflection-based registration.

These costs are not automatically serious, but they are a reason not to treat DLL packaging as a runtime optimization.

For runtime performance, use the Unity Profiler and optimize the actual bottleneck: allocations, algorithms, data layout, Burst, Jobs, rendering, loading, or networking.

The "speed" a DLL most commonly improves is:

  • The time from editing code to returning to Play Mode.
  • The speed of pure .NET tests that do not launch Unity.

Keeping these two definitions separate makes the result easier to measure.

Benefit 3: Run Unit Tests Without Starting Unity

A Unity-independent DLL can be referenced by a normal .NET test project.

src/
├── Shared.GameRules/
│   └── Shared.GameRules.csproj
└── Shared.GameRules.Tests/
    └── Shared.GameRules.Tests.csproj
Enter fullscreen mode Exit fullscreen mode

The test project can use xUnit, NUnit, MSTest, or any other compatible framework.

public sealed class DamageFormulaTests
{
    [Theory]
    [InlineData(100, 20, 150, 130)]
    [InlineData(10, 999, 100, 1)]
    public void Calculate_ReturnsExpectedValue(
        int attack,
        int defense,
        int skillPower,
        int expected)
    {
        var parameter = new BattleParameter(
            attack,
            defense,
            skillPower);

        var actual = DamageFormula.Calculate(parameter);

        Assert.Equal(expected, actual);
    }
}
Enter fullscreen mode Exit fullscreen mode

This enables a different development loop:

  • Run tests in seconds without opening the Unity Editor.
  • Use a lightweight Linux CI runner.
  • Run the library in the same pipeline as server tests.
  • Avoid Unity licenses and Library cache setup for pure logic tests.
  • Use the IDE's standard test runner.
  • Add coverage, mutation testing, and benchmarks.

Unity Test Framework can also run EditMode tests. The point is not that Unity tests are bad; it is that Unity-independent logic does not need to pay the cost of Unity import, compilation, and the Unity test runner.

Fast tests make it practical to cover more edge cases: zero, negative values, maximum values, overflow, rounding, level caps, time boundaries, and invalid inputs.

Keep Unity Integration Tests in Unity

External .NET tests cannot detect every problem. They do not validate:

  • MonoBehaviour lifecycle behavior.
  • Unity serialization.
  • Mapping to ScriptableObject data.
  • IL2CPP stripping.
  • Main-thread restrictions.
  • Platform-specific file or network behavior.

Use a two-level strategy:

Normal .NET tests
  └ Fast, broad verification of shared logic.

Unity EditMode / PlayMode / device tests
  └ Unity integration, AOT, serialization, and platform behavior.
Enter fullscreen mode Exit fullscreen mode

DLLs do not eliminate Unity tests. They let pure logic run in a more appropriate environment.

Benefit 4: Enforce Dependency Direction at Compile Time

When all source is inside one Unity project, developers can often reference engine state simply because it is available.

Examples include calling a persistence service directly from UI code or using GameObject.Find inside domain logic.

A separate netstandard2.1 project does not reference UnityEngine by default:

// Does not compile in Shared.GameRules.
var player = GameObject.Find("Player");
Enter fullscreen mode Exit fullscreen mode

The limitation becomes architectural protection:

  • Domain logic cannot search scenes.
  • Time is passed in or provided through a clock abstraction rather than read from DateTime.Now everywhere.
  • Randomness is abstracted instead of hardcoded to UnityEngine.Random.
  • Logging does not depend directly on Debug.Log.
  • Core file operations do not hardcode Application.persistentDataPath.

For example, a time-dependent rule can receive a clock:

using System;

public interface IClock
{
    DateTimeOffset UtcNow { get; }
}

public sealed class EnergyRecoveryService
{
    private readonly IClock _clock;

    public EnergyRecoveryService(IClock clock)
    {
        _clock = clock;
    }

    public int Calculate(
        DateTimeOffset lastRecoveredAt,
        int recoverySeconds)
    {
        if (recoverySeconds <= 0)
        {
            throw new ArgumentOutOfRangeException(
                nameof(recoverySeconds),
                recoverySeconds,
                "Recovery seconds must be greater than zero.");
        }

        var elapsed = _clock.UtcNow - lastRecoveredAt;
        if (elapsed <= TimeSpan.Zero)
        {
            return 0;
        }

        var elapsedSeconds =
            elapsed.Ticks / TimeSpan.TicksPerSecond;
        var recoveredAmount =
            elapsedSeconds / recoverySeconds;

        return recoveredAmount >= int.MaxValue
            ? int.MaxValue
            : (int)recoveredAmount;
    }
}
Enter fullscreen mode Exit fullscreen mode

Unity can provide a Unity-facing implementation, the server can provide a server implementation, and tests can provide a fixed clock. Configuration such as recoverySeconds is validated at the boundary, preventing zero division and invalid behavior from entering the shared rule.

The calculation uses integer ticks instead of going through TimeSpan.TotalSeconds as a double. Negative elapsed time becomes zero, and very large values saturate to int.MaxValue. This makes the rounding and upper-bound policy explicit and easier to keep identical on the client and server.

You can create the same architecture with asmdef files and strict conventions, but a separate project makes a Unity dependency a compilation error rather than a code-review preference.

Keep the Public API Small

Every public type in a DLL becomes a contract for Unity, the server, tests, and tools. If every internal class is public, consumers will depend on implementation details that become difficult to change.

Expose inputs and final results rather than every intermediate state:

public readonly struct DamageResult
{
    public DamageResult(int value, bool isCritical)
    {
        Value = value;
        IsCritical = isCritical;
    }

    public int Value { get; }
    public bool IsCritical { get; }
}

public interface IDamageCalculator
{
    DamageResult Calculate(
        BattleParameter parameter,
        int randomValue);
}
Enter fullscreen mode Exit fullscreen mode

Passing a random value into the calculation makes the rule deterministic for a given input. If exact client/server agreement is required, specify whether you share the random value, a seed plus a fixed algorithm, or only the server-produced result.

Public API guidelines that help preserve compatibility include:

  • Do not expose UnityEngine types from the Core DLL.
  • Choose arrays or IReadOnlyList<T> based on the caller's real needs.
  • Avoid unrestricted setters.
  • Assign explicit numeric values to enums used in protocols or persisted data.
  • Treat optional parameter defaults as part of the contract.
  • Remember that renaming a public parameter can affect callers using named arguments.
  • Do not use exceptions as an ordinary branch result.
  • Default to internal and make only intentional entry points public.

The library's value is not the number of classes it contains. It is the stability of the boundary.

Split DLLs by Reasons to Change

Dividing DLLs by file count usually causes all of them to be released together anyway. A better boundary reflects who changes the code, why it changes, and how often it changes.

Company.Protocol
  └ Network contracts.
     Updated with client/server agreement.

Company.GameRules
  └ Calculations and validation.
     Updated with game-design rules.

Company.Foundation
  └ Logging, IDs, and shared data structures.
     Updated infrequently by a platform team.

Company.UnityAdapter
  └ Unity API integration.
     Updated for engine and presentation changes.
Enter fullscreen mode Exit fullscreen mode

If protocol contracts and Unity adapters live in the same DLL, a Unity-only update can unnecessarily force a server-side package update. On the other hand, splitting too aggressively increases the number of packages, references, and version combinations.

A useful rule is to split code that needs a separate release reason, has different consumers, or needs a one-way dependency boundary. A namespace difference alone does not justify another DLL.

Benefit 5: Distribute the Same Version to Multiple Projects

Copying source into each Unity repository makes it difficult to know which project contains which revision:

ProjectA/Assets/Common/...
ProjectB/Assets/Common/...
ProjectC/Assets/Common/...
Enter fullscreen mode Exit fullscreen mode

A built and versioned DLL makes the dependency explicit:

Shared.GameRules 2.3.1
Shared.Networking 4.0.0
Company.Logging 1.8.2
Enter fullscreen mode Exit fullscreen mode

Possible distribution methods include:

  • Commit the DLL directly to the consumer repository.
  • Put the DLL in a Unity Package.
  • Publish a UPM package through a scoped registry.
  • Publish the .NET package to an internal NuGet feed and copy its DLL into a generated Unity package.
  • Synchronize a CI artifact into the Unity project.

For companies maintaining several games, knowing the exact version in use is often more important than always running the latest version.

Automatic updates can break all projects at once. Pin each consumer to a known version, test the upgrade, and then update deliberately.

DLLs Fit Semantic Versioning Well

A public DLL API maps naturally to Semantic Versioning:

  • Patch: compatible bug fixes.
  • Minor: backward-compatible additions.
  • Major: removed or renamed types, changed parameters, and other breaking changes.

Deleting or renaming an assembly, moving a public type, or changing public method signatures should be treated as a breaking change.

A physical binary boundary encourages teams to distinguish between freely changeable internal implementation and public compatibility commitments.

Benefit 6: Deliver Functionality Without Shipping Source

A Unity Asset Store tool, external SDK, or partner library may need to be distributed as a DLL instead of source code.

Unity's documentation lists supplying code without source as one use case for managed plug-ins.

Benefits include:

  • Consumers are less likely to modify internal implementation accidentally.
  • The deliverable can be smaller and more controlled.
  • The public API becomes an explicit product surface.
  • Implementation details are less visible during ordinary use.
  • The same binary can be delivered to multiple consumers.

However, managed DLLs can be decompiled. Obfuscation can increase the cost of analysis, but it does not make the code secret.

DLL packaging is not a substitute for security:

  • Do not embed API keys.
  • Do not ship server private keys.
  • Do not put the final billing, reward, or entitlement decision in the client.
  • Do not rely on the DLL format for copy protection.
  • Keep license terms and technical enforcement as separate concerns.

A DLL is useful for preventing casual edits and keeping implementation out of the normal source view. It cannot guarantee that nobody will inspect the code.

Should the DLL Reference UnityEngine?

A Unity-specific SDK may legitimately need to call Unity APIs from inside a DLL.

Technically, you can build such an assembly by referencing Unity modules such as UnityEngine.CoreModule.dll. For code shared with a server, however, this creates an unnecessary dependency.

A better package layout is:

Company.Product.Core.dll
  └ netstandard2.1, no Unity dependency.

Company.Product.Unity.dll
  └ UnityEngine-dependent adapters.

Company.Product.Editor.dll
  └ UnityEditor-dependent editor tooling.
Enter fullscreen mode Exit fullscreen mode

Core can be used by servers, CLIs, and tests. Unity converts between Unity types and the core model and contains Components. Editor contains importers, inspectors, and menu commands.

If you do build a Unity-dependent DLL, manage at least the following:

  • The Unity version whose assemblies are referenced.
  • Separation of runtime and editor assemblies.
  • Plugin Importer platform settings.
  • Rebuilding when Unity APIs change.
  • Preventing UnityEditor references from entering Player builds.
  • Changes that Unity's API Updater cannot rewrite inside the precompiled binary.

The thinner the Unity-specific layer is, the easier it is to support multiple Unity LTS versions.

Disadvantages of External DLLs

The benefits come with operational costs.

More Steps Before a Change Appears in Unity

A source file in Unity is recompiled after saving. An external DLL must be built and copied or packaged.

File watching, IDE tasks, automatic builds, and CI artifacts can reduce the friction. The most effective solution is still to avoid placing frequently edited code in the DLL.

Weaker Inspector and Source Navigation Workflows

A DLL can contain MonoBehaviour types, but the source is not a normal script asset in the project. Navigation, script assets, serialization compatibility, and class renames require more care.

Game-specific Components are usually easier to maintain in Unity source, with the DLL focused on logic.

Transitive Dependencies Must Be Shipped

If the .NET project references a NuGet package, Unity also needs the compatible dependent assemblies. Unity does not resolve .deps.json in the same way as a normal .NET application.

Different Unity packages may also ship conflicting versions of the same assembly.

Prefer few external dependencies. When one is necessary, verify:

  • A netstandard2.1 asset exists.
  • IL2CPP support is documented.
  • Native binaries are not required, or are available for every target.
  • Transitive dependencies are known and packaged.
  • Same-name assembly conflicts can be managed.
  • Redistribution is permitted by the license.

Public API Changes Become More Expensive

Internal implementation remains flexible, but a public API change can affect Unity, the server, and every tool at once.

This is a cost, but it also creates useful pressure to keep dependencies clean. Avoid a single enormous interface. Prefer small types and purpose-specific services.

DLLs Are Not a Security Boundary

Managed assemblies remain inspectable. DLLs are a packaging and architecture choice, not a secret-storage mechanism.

A Gradual Migration Plan

A large Unity project does not need to move thousands of files at once.

Step 1: Find Unity-Independent Code

Good candidates usually have these properties:

  • No using UnityEngine;.
  • Static or input/output-oriented calculations.
  • DTOs and value objects.
  • Validation rules.
  • String, time, and numeric conversion.
  • Pure data processing before or after networking.
  • Testable without scenes or editor state.

Step 2: Create a Boundary with asmdef First

Before moving code to another repository or project, create something such as Game.Domain.asmdef inside Unity and correct the dependency direction.

If the assembly still references large parts of Unity, the architecture needs separation before it needs packaging.

Step 3: Move the Assembly to an External .NET Project

Replace Unity-specific parameters with standard or domain-specific values where appropriate:

// Avoid in a cross-runtime core library.
int Calculate(Vector3 position);

// Easier to share.
int Calculate(Position3 position);
Enter fullscreen mode Exit fullscreen mode

Do not create custom replacements for every Unity type without a real reason. If vector math does not need to be shared, leaving it in the Unity layer may be the better choice.

Step 4: Add Normal .NET Tests

Before and after the move, create characterization tests that prove behavior remains the same. Lock down calculations, rounding, caps, random input, and date boundaries.

Step 5: Import the DLL into Unity

Part 2 covers global.json, csproj, Plugin Importer, and .meta management.

It is acceptable to begin with Auto Referenced enabled. After the integration works, restrict the reference graph with asmdef files and Override References.

Step 6: Verify IL2CPP

Pay special attention to reflection, serialization, generics, async code, and threading. Editor success is not the final validation.

Step 7: Automate Distribution and Version Updates

Replace manual copying with a pipeline that produces the DLL, PDB, XML documentation, license, and linker configuration as one artifact.

Decision Table

Goal or situation Recommended first choice
Improve Unity compilation time only Start with asmdef
Remove mature, large code from the daily edit loop Consider an external DLL
Share calculations between Unity and a C# server A Unity-independent DLL is a strong option
Share code with a CLI or data-processing tool A DLL is a strong option
Edit MonoBehaviour classes every day Unity source plus asmdef
Test Unity-independent logic quickly External DLL or independent .NET project
Make code impossible to inspect DLLs do not solve this
Distribute one foundation library to several games DLL plus UPM package

The central criteria are not file count. They are change frequency and number of consumers. Stable code used outside Unity and tested independently gains the most from an external DLL.

Conclusion

The practical benefits of turning selected Unity code into an external managed DLL are:

  1. Share calculations and validation between a Unity client and a C# server.
  2. Remove mature code from Unity's normal source recompilation loop.
  3. Run fast, standard .NET tests without starting Unity.
  4. Prevent Unity dependencies through compile-time project boundaries.
  5. Distribute a versioned artifact to multiple games.
  6. Treat the public API as an explicit compatibility contract.
  7. Use asmdef first when compilation time is the only goal.
  8. Do not expect packaging alone to improve runtime performance.

The most valuable combination is usually the ability to use the same game rules on the client and server and to test those rules extensively without Unity.

At the same time, sharing one DLL is not enough to guarantee identical behavior. Inputs, rounding, time, randomness, culture, and overflow policies must also be aligned, while the server remains authoritative.

A useful final architecture is:

Stable pure logic
  └ Unity-independent external DLL.

Frequently changing gameplay code
  └ Unity source divided with asmdef files.

Unity integration
  └ Thin MonoBehaviour and ScriptableObject adapters.

Server-specific behavior
  └ Modern .NET server projects.
Enter fullscreen mode Exit fullscreen mode

Part 2 shows how to implement this structure with a fixed .NET SDK, a netstandard2.1 project, newer C# syntax inside the DLL, Plugin Importer settings, IL2CPP/AOT testing, reproducible artifacts, and debugging symbols.

References

Top comments (0)