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();
}