So, you wanted to hear about build systems you say! Well, have I got a devlog post for you.
Greg Harding from Flightless, the developer of the upcoming Element, got in touch last week asking about how we managed the complexity of producing the various builds of Mini Metro (we now deliver to 7 different storefronts over 3 platforms, with mobile builds to add to that soon). I realised it’s a problem that deserves a bit more attention than a few tweets, so decided to write it up here.
The gold-standard for any game’s build system is to be able to produce a build from a single command executed at the command-line. No build options you need to change manually in Visual Studio, or code you have to remember to comment in or out—just one command to build all platforms. Unity’s existing build configuration system is decent enough to produce cross-platform builds. It just takes a call to BuildPipeline.BuildPlayer, which can be can put into a C# function and called from the command line. What it’s not good at is making variants per-platform; for instance, a Windows build for Steam, a Windows build for GOG, a Windows build for the Humble Store, etc.
Our friend, build.py
To cater for this, what started off as fairly humble Python script turned into quite an involved system. A typical command we’ll run to execute the build script is something like:
python build/build.py gamma4 all.steam win32.humble win32.gog --action=deploy
This will make a build named gamma4, for all platforms supported by Steam, and a Windows build for the Humble Store and GOG, and deploy them all. It does this in the following steps:
- Treat the first command-line parameter as the name of the build. (This is the most error-prone part! I’ve often generated a build named ‘win32.steam’).
- All other parameters are treated as build targets. Each target is assumed to be of the format platform.variant. We have a list of all supported platforms and variants, and which variants support which platforms, so any dodgy requests are flagged. If all.variant is specified, we build for all of that variant’s platforms.
- Parse other parameters. Right now we only have two: –debug generates a build we can connect to with the Unity profiler, and –action dictates whether or not the script deploy the builds (more on that below).
- For each build target in turn, the script creates a temporary folder and generates a new Unity project for the build. It does this by selectively copying in content from the development project. It skips the Temp and Library folders, and only copies in plugins required by that particular build. For example, CSteamworks.NET is only copied in for Steam builds, and GameSparks only for builds connecting to Facebook. The iOS build doesn’t support precompiled native plugins, and instead copies the appropriate .c files from the source of our native plugins. For mobile builds, we include only the 24kHz audio bank and the SD and HD font textures (desktop builds include 44.1kHz and 48kHz banks and fonts for 12 resolutions).
- One C# file in the new project, Version.cs, is edited from the script. The timestamp and build name is written into this file, so we can identify crash reports and logs once the build is in the wild.
- The script starts the new Unity project from the command-line, executing a C# function to configure the project for building. This function does two things. First, it loads the cities to ensure they’re all compiled to binary from JSON. Second, it sets up the defines for the build target with PlayerSettings.SetScriptingDefineSymbolsForGroup. VARIANT_STEAM will be set for a Steam build for example, VARIANT_HUMBLE for the Humble Store, etc. All vendor-specific code is included between the appropriate #ifdef.
- Once the project has been configured, the script then runs the Unity project a second time, calling another C# function, this time to actually build a player. We found this two-step process was necessary, otherwise the player would be compiled without the defines we’d specified. This function is much simpler; all it does is make the appropriate call to BuildPipeline.BuildPlayer, including just the main Unity scene for mobile builds, or the bootstrap scene as well for the desktop builds (this additional scene configures the resolution before the main script kicks off).
- And we’re done! Well, almost. Any errors generated by either of the two executions of Unity are picked up and logged to the terminal, and the temporary project is destroyed.
- Repeat from step 4 for each build target.
Once we’d officially released Mini Metro, we also made it available across many more storefronts than just Steam and the Humble Store. Once we’d released the first update, it became apparent that distributing these builds was a manual, error-prone task that we had to automate.
This became an optional task we tacked onto the end of our build script. Each variant now has a configurable deployment target specified. The Steam variant, for example, deploys through Steam’s proprietary build system that automatically uploads the three desktop builds and activates them on the private dev branch. The GOG variant will upload to their FTP server. The other DRM-free versions will SCP into our server, and upload the builds into private folders accessible through FTP. We also tried deploying through Google Drive, but we found the uploads through the Google Drive API too slow and the daily bandwidth limit too restrictive.
The first implementation ran the deployment step for each build once all the builds were complete. This was fairly naïve, as doing a full build involves 19 individual build targets and takes around half an hour. Uploading took another hour on top of that, when the script could pipeline the whole process by beginning to upload each build as soon as they were completed. Once the deployment was threaded the entire process was capable of running in an hour.
The automatic deployment took perhaps a day to implement. The release of the gamma4 build took another whole day, most of which was due to fixing bugs in the deployment script. 🙂 But we can see it’ll save us a ton of time in the long run.
It’s good at what it does, but Mini Metro’s build script isn’t much of a system. All it does is automate the configuration, building, and deployment of builds. There are a number of ways we’d like to improve it, including:
- Fully automating iOS builds. Unity’s process for building iOS apps is a little peculiar in that it generates an Xcode project that you have to also build, instead of a runnable app. We haven’t looked at automating that yet.
- Running unit tests. We have no automated testing at all, and have shipped a number of regressions that could have been easily picked up.
- Having some persistence to the builds, and a managed workflow from build, to test, to deploy. Right now we have no way to produce a build, test it, then deploy it once testing is complete.
I hope that was mildly interesting and of use to somebody!