How to improve scripts patching for faster development, especially for multiplayer games

I’ve recently decided to port my multiplayer game from Unreal Engine to Unity. One of the biggest issue in doing that is how slow is to test multiple network clients compared to UE.

Since Unity does not allow to have multiple Editor instances open for the same project, the only option is to build the game and run multiple instances of it. But that takes too much time when you’re continuously doing script changes. There is the “Scripts Only Build” option, but that is too slow.

I was starting to regret my decision for leaving Unreal, until this week when I’ve finally managed to improve iteration time tremendously.

To improve the workflow, we should only patch the scripts for an existing build. We have three options for that, and I’ll present them next, alongside with their respective time it takes to have the game open and ready for testing:

1. Scripts only build using the build settings window:
Windows: 28 seconds
Android (“Patch And Run” button): 42 seconds

2. Copy the DLLs for the compiled scripts from Editor into the build (my preferred option):
Windows: 1 second
Android: 4 seconds

3. Manually compiling the scripts and copying the DLLs into the build:
Windows: 6 seconds
Android: 9 seconds

Disclaimer:

  • I’m using Mono scripting backend for development builds and patching
  • I only tested on Windows and Android
  • I’m using Unity 2020.1 Alpha

About Option 1 (Scripts Only Build)

I don’t know what’s going on when doing a “Scripts Only Build”, but it’s doing more than just scripts only build. It gets stuck for a good amount of time with a “Build Player” dialog open.

Less than a minute it’s not that bad, but while continuously writing network code and testing multiple players, that is way too long, especially when I’ve been spoiled by UE.

About Option 2 (copy DLLs from Editor)

This is the fastest option and is the one I’m gonna use. But there is one big “gotcha” to it: the compiled scripts will contain editor code (when using #if UNITY_EDITOR preprocessor directive) that might call editor functions that are not available inside standalone builds.

To bypass the issue, I’m putting all the editor code inside Editor assemblies and restricting my usage of #if UNITY_EDITOR to only specific code that is not a problem if it will be executed without the editor.

How to patch:
Get a list of the project assemblies (using CompilationPipeline.GetAssemblies) and copy the compiled DLLs from the project’s “Library/ScriptAssemblies” to “/_Data/Managed”.

To patch a build on Android, you’ll have to copy the DLLs to “/storage/emulated/0/Android/data//cache/ScriptOnly//mono/Managed” (see Unity - Manual: Application patching)

For Android, an extra file is needed for the game build to load the DLLs from different location:
/storage/emulated/0/Android/data//cache/ScriptOnly//mono/patch.config”.
The file should contain a patch date taken from DateTime.Now.Ticks: patchDate=637137256109191963

(code example at the end of the post)

About Option 3 (compile scripts first)

This option is also a faster alternative to “Scripts Only Build”. Compared to option #2, the editor code is removed from DLLs, which is great. The only downside is that the time is dependent on how fast the compilation is. Plus, it’s kinda redundant: you modify scripts, switch to the editor, wait for compile, then patch by compiling again.

To avoid the double-compilation, the auto-compile could be disabled for the editor. That I guess will give you a patch time similar to option #2. But of course, this could work only when you do not need your changes to be visible inside the editor (you would play-test the game using the patched builds).

The patching process is similar to option #2, and you can find a code example bellow. The actual compilation is being done with PlayerBuildInterface.CompilePlayerScripts.

Side Note: UI Elements made it really easy and fast to build a custom tool to automate the building and patching process.

Here’s a code example on how the patch options could be implemented:

public const string UnityPlayerActivity = "com.unity3d.player.UnityPlayerActivity";

public static BuildTarget selectedPlatform => EditorUserBuildSettings.activeBuildTarget;
private static BuildTargetGroup selectedPlatformGroup => BuildPipeline.GetBuildTargetGroup(selectedPlatform);

private static readonly string AdbPath = Path.Combine(
    EditorApplication.applicationContentsPath,
    "PlaybackEngines",
    "AndroidPlayer",
    "SDK",
    "platform-tools",
    "adb.exe"
);

private static string buildDirectory
{
    get
    {
        GameBuilderSettings settings = GameBuilderSettings.Get();

        switch (selectedPlatform)
        {
            case BuildTarget.StandaloneWindows64:
                return Path.Combine(settings.buildPath, settings.windowsDirectory);

            case BuildTarget.Android:
                return Path.Combine(settings.buildPath, settings.androidDirectory);

            default:
                return "";
        }
    }
}

private static string appPath
{
    get
    {
        switch (selectedPlatform)
        {
            case BuildTarget.StandaloneWindows64:
                return Path.Combine(buildDirectory, $"{Application.productName}.exe");

            case BuildTarget.Android:
                return Path.Combine(buildDirectory, $"{Application.productName}.apk");

            default:
                return "";
        }
    }
}

private static string patchDirectory => Path.Combine(buildDirectory, $"{Application.productName}_Data", "Managed");

private static string patchDirectoryAndroid
    => $"/storage/emulated/0/Android/data/{Application.identifier}/cache/ScriptOnly/{Application.unityVersion}/mono";

private static string patchTempDirectory => Path.Combine(buildDirectory, "CompiledScripts");


public static void Patch(PatchType patchType = PatchType.FromEditor, bool run = false)
{
    if (patchType == PatchType.FromEditor)
    {
        PatchFromEditor();
    }
    else if (patchType == PatchType.Compile)
    {
        PatchCompile();
    }

    Debug.Log($"patchDate={DateTime.Now.Ticks}");

    if (selectedPlatform == BuildTarget.Android)
    {
        Process process = new Process {
            StartInfo = {
                FileName = AdbPath,
                Arguments = $"shell echo patchDate={DateTime.Now.Ticks} > {patchDirectoryAndroid}/patch.config",
                WindowStyle = ProcessWindowStyle.Hidden
            }
        };

        process.Start();
        process.WaitForExit();
    }

    if (run)
    {
        RunBuild();
    }
}

private static void PatchFromEditor()
{
    Assembly[] assemblies = CompilationPipeline.GetAssemblies(AssembliesType.PlayerWithoutTestAssemblies);

    foreach (Assembly assembly in assemblies)
    {
        if (assembly.sourceFiles[0].Contains("com.unity"))
        {
            continue;
        }

        PatchFile(assembly.outputPath);
        PatchFile(assembly.outputPath.Replace(".dll", ".pdb"));
    }
}

private static void PatchCompile()
{
    ScriptCompilationSettings compileSettings = new ScriptCompilationSettings {
        group = selectedPlatformGroup,
        target = selectedPlatform,
        options = ScriptCompilationOptions.DevelopmentBuild
    };

    PlayerBuildInterface.CompilePlayerScripts(compileSettings, patchTempDirectory);

    foreach (string filePath in Directory.GetFiles(patchTempDirectory))
    {
        if (Path.GetFileName(filePath).StartsWith("Unity", StringComparison.Ordinal))
        {
            continue;
        }

        PatchFile(filePath);
    }

    FileUtil.DeleteFileOrDirectory(patchTempDirectory);
}

private static void PatchFile(string filePath)
{
    string fileName = Path.GetFileName(filePath);

    if (fileName == null)
    {
        return;
    }

    if (selectedPlatform == BuildTarget.StandaloneWindows64)
    {
        FileUtil.ReplaceFile(filePath, Path.Combine(patchDirectory, fileName));
    }
    else if (selectedPlatform == BuildTarget.Android)
    {
        CopyFileToAndroidDevice(filePath, $"{patchDirectoryAndroid}/Managed/{fileName}");
    }
}

private static void CopyFileToAndroidDevice(string src, string dst)
{
    Process process = new Process {
        StartInfo = {
            FileName = AdbPath,
            Arguments = $"push {src} {dst}",
            WindowStyle = ProcessWindowStyle.Hidden
        }
    };

    process.Start();
    process.WaitForExit();
}

public static void RunBuild()
{
    Process process = new Process();

    if (selectedPlatform == BuildTarget.StandaloneWindows64)
    {
        process.StartInfo.FileName = appPath;
    }
    else if (selectedPlatform == BuildTarget.Android)
    {
        process.StartInfo.FileName = AdbPath;
        process.StartInfo.WindowStyle = ProcessWindowStyle.Hidden;

        string closeCmd = $"am force-stop {Application.identifier}";
        string openCmd = $"am start {Application.identifier}/{UnityPlayerActivity}";

        process.StartInfo.Arguments = $"shell {closeCmd} && {openCmd}";
    }

    process.Start();
}
2 Likes

Or you could just use SyncToy to clone your project and run 2 instances of Unity.

Make any changes to the main project, hit sync and play on both instances.

1 Like

This is an amazing idea brav, let me give it a try and get back to you on how it goes

As convenient as this is, this and tools similar to Parallel Sync all have the problem of running two versions of the editor that both need to update and maintain compiled script assemblies, this increases the RAM and total time taken to recompile scripts. In my project, with one editor open it takes 30s to recompile, with 2 open it take 3 minutes to recompile. That’s an issue. It is very convenient, but with the downside of greatly increasing development time.

I have tried it, and yes, it works amazing. I think unity really should implement such tools out of the box

1 Like