Building a Unity project with Teamcity & Kotlin DSL: a quick guide

MY.GAMES
MY.GAMES
Published in
8 min readDec 13, 2023

This guide for getting started will walk you through getting a full Unity build with Teamcity (a CI system great for organizing the building process), and Kotlin DSL. We’ll get started and tease out some of the possibilities that are possible with this configuration!

Hello! My name is Mikhail Rublev, and I work as an infrastructure engineer at MY.GAMES. In this article, I’ll share our experience interacting with Teamcity, a CI system. This system provides great opportunities for organizing the building process; one of the most important features is its ability to describe and store the build configuration in Kotlin DSL.

At MY.GAMES we are driven by product success, and our focus is on creating games that become leaders within their categories. Therefore, we are constantly improving our work process and introducing new solutions and technologies.

Including Kotlin DSL in Teamcity settings

In order to take advantage of this feature, you need to select Versioned Settings in the project settings. Set Synchronization enabled, select the repository, and set Kotlin in Settings format.

(It’s important to uncheck Allow editing project settings via UI.) Next, click the Apply button.

Features of using Kotlin DSL

Let’s go into a little more detail. Teamcity has a cool feature that allows us to save UI changes directly in Kotlin DSL by committing to the repository.

This feature is quite convenient (to illustrate, it allows you to see the history of changes), but it is only useful until we’ve started working directly with the code. Then, if, after certain edits in the code, we again make changes to the UI, Teamcity no longer “knows” how to correctly make changes in Kotlin. It generates so-called “patches” (the Patches directory is created), where the UI changes are stored, and it then asks us to make these changes manually, before deleting the Patches folder.

This feature shouldn’t be overused — it’s better to move on to writing code in Kotlin DSL as early as possible. Otherwise, there is a high chance that patches will accumulate and developers will simply forget to work with them. Teamcity can sometimes generate huge patches — and transferring them to a project isn’t exactly a simple task. (For example, this kind of patch can be generated when we try copying a project to a new one using UI tools.)

Another point worth considering is that a project structure with automatic changes will not necessarily coincide with the project structure in the UI. During the initial stages, Teamcity will store everything in one configuration file called settings.kts, but as the project grows, its structure in Kotlin will be divided into packages — however, as we wrote above, it may differ from what we expect. Just an example: subprojects could be placed at the same hierarchical level as the main project. So, we recommend breaking the project into packages yourself from the get go.

From the UI to Kotlin DSL

Let’s create a simple project that will build a game client for the Android and Web platforms:

Now, let’s see how we can create the same project structure in Kotlin DSL:

We’ll take a closer look at each file to understand their respective responsibilities.

pom.xml is a Maven configuration file generated by Teamcity when the project is initialized. It lists, along with the project name, the different dependencies that Teamcity requires.

settings.kts is the entry point; this is the place where Teamcity stores the entire project description, including all builds, at the initial stages. We want to immediately build the necessary file structure so that it repeats the same structure as in the UI.

So, we’ll just keep the following in the settings.kts file:

// settings.kts

import jetbrains.buildServer.configs.kotlin.v2019_2.project
import jetbrains.buildServer.configs.kotlin.v2019_2.version

version = "2023.05"
project(_Self.Project)

In the _Self folder we’ll have a vcsRoots folder; this is where we’ll indicate the repositories needed for the build. In this case, this will be the file MyGamesRepo.kt where the repository with the client code for our game is indicated. The repository description in the MyGamesRepo.kt file looks like this:

// MyGamesRepo.kt
package _Self.vcsRoots

import jetbrains.buildServer.configs.kotlin.v2019_2.vcs.GitVcsRoot

object MyGamesRepo: GitVcsRoot({
name = "MyGamesRepo"
url = "ssh://git@gitlab.youre.repo.domain/itt/mygame.git"
branch = "master"

branchSpec = """
refs/heads/*
""".trimIndent()
authMethod = uploadedKey {
uploadedKey = "secret.key"
}
})

At the root of _Self is a Project.kt file; this is where we can state global variables for the entire project. Additionally, using the subProject method, here we’ll define the structure of the subproject where the clients of our game for the Android and Web platforms will be collected:

// Project.kt
package _Self

import jetbrains.buildServer.configs.kotlin.v2019_2.ParameterDisplay
import jetbrains.buildServer.configs.kotlin.v2019_2.Project

object Project : Project({

params {
text(
"PROJECT_NAME",
"MyGame",
display = ParameterDisplay.HIDDEN
)
}

subProject(Client.ClientProject)
})

The ClientProject.kt file, which is placed in the root of the Client folder, will contain a description of our subproject:

// ClientProject.kt
package Client

import Client.buildTypes.Android
import Client.buildTypes.Web
import jetbrains.buildServer.configs.kotlin.v2019_2.Project
object ClientProject : Project({
id("Client")
name = "Client"

buildType(Android)
buildType(Web)
})

The Common folder will contain build steps and classes, which allows us to avoid code duplication:

We’ll get back to this a little later.

Creating a wrapper for the CLI interface

Let’s assume we’re building a Unity project using this command:

/Applications/Unity/Hub/Editor/2022.3.12f1/Unity.app/Contents/MacOS/Unity -projectPath <projectPath> -executeMethod <executeMethod> -buildTarget <buildTargetPlatform> -quit -nographics -batchmode -logfile

This means that Unity has a command line interface, and we could simply write a build step in Teamcity like this:

script {
name = “Build Android Unity Game”
scriptContent = “””
/Applications/Unity/Hub/Editor/2022.3.12f1/Unity.app/Contents/MacOS/Unity -projectPath <projectPath> -executeMethod <executeMethod> -buildTarget <buildTargetPlatform> -quit -nographics -batchmode -logfile
“””.trimIndent()
init(this)
}

But we’ll do things a little differently. We’ll create a new class UnityCLI.kt in the Common folder and place the following code there:

// UnityCLI.kt
package Common

import jetbrains.buildServer.configs.kotlin.v2019_2.BuildStep
import jetbrains.buildServer.configs.kotlin.v2019_2.BuildSteps
import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.ScriptBuildStep

class UnityCLI() : ScriptBuildStep() {
var unityPath: String? = null
var buildTargetPlatform: String? = null
var executeMethod: String? = null
var projectPath: String? = null

constructor(block: UnityCLI.() -> Unit) : this() {
block()
scriptContent = """
${unityPath} -projectPath ${projectPath} -executeMethod ${executeMethod} -buildTarget ${buildTargetPlatform} -quit -nographics -batchmode -logfile
""".trimIndent()
}
}

fun BuildSteps.unity(block: UnityCLI.() -> Unit): BuildStep {
val result = UnityCLI(block)
step(result)
return result

With that, we’ve created a wrapper around the command line interface. And now, instead of a script block calling Unity from the command line directly, we can use a construction like this:

unity {
name = "Build Android Unity Game"
unityPath = "%env.UNITY3D_INSTALL_PATH%"
projectPath = "MyGames"
buildTargetPlatform = "Android"
executeMethod = "MyGame.Editor.Build"
}

The full Android build implementation

Let’s see how Android.kt looks:

// Android.kt
package Client.buildTypes

import Common.macOS
import Common.unity
import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType
import jetbrains.buildServer.configs.kotlin.v2019_2.DslContext
import jetbrains.buildServer.configs.kotlin.v2019_2.ParameterDisplay

object Android : BuildType({
id("ClientAndroidBuild")
name = "Android"

artifactRules = """
MyGame/*.apk
""".trimIndent()


params {
text(
"env.UNITY3D_INSTALL_PATH",
"/Applications/Unity/Hub/Editor/2022.3.12f1/Unity.app/Contents/MacOS/Unity",
display = ParameterDisplay.HIDDEN
)
}

vcs {
cleanCheckout = true
root(DslContext.settingsRoot)
}

steps {
unity {
name = "Build Android Unity Game"
unityPath = "%env.UNITY3D_INSTALL_PATH%"
projectPath = "MyGames"
buildTargetPlatform = "Android"
executeMethod = "MyGame.Editor.Build"
}
}

failureConditions {
executionTimeoutMin = 180
}

requirements {
macOS()
}
})

In addition to the main call in the build file, there is an artifactRules construction. Here, as you might guess, we’ll indicate the path to the artifacts of this build. These files can be downloaded after the build is completed in the Artifacts tab:

The path to the installed version of Unity was made into a separate variable:

params {
text(
"env.UNITY3D_INSTALL_PATH",
"/Applications/Unity/Hub/Editor/2022.3.12f1/Unity.app/Contents/MacOS/Unity",
display = ParameterDisplay.HIDDEN
)

In the future this will allow us, if necessary, to modify this variable using the Teamcity mechanism. This is done using a construction in bash or any other scripting language. Here’s an example of that:

echo "##teamcity[[setParameter name='env.UNITY3D_INSTALL_PATH' value='Any_path_you_want']]"

In the vcs construction, we want to use the main repository (declared in the Teamcity settings at the very start) and we want to have a clean copy upon checkout:

vcs {
cleanCheckout = true
root(DslContext.settingsRoot)
}

In the failureConditions block, we’ll limit the build time to avoid builds that may be executed forever (if, for example, Unity crashes):

failureConditions {
executionTimeoutMin = 180
}

Next, comes the requirements block:

requirements {
macOS()
}

Here we indicate the build agents, on which this or that build can be executed. For convenience, we have put this in Requirements.kt

// Requirments.kt
package Common

import jetbrains.buildServer.configs.kotlin.v2019_2.Requirements


fun Requirements.macOS() {
equals("teamcity.agent.jvm.os.name", "Mac OS X")
}

Here we can define methods for different platforms or limit build agents by name; for example:

fun Requirements.windowsOS() {
equals("teamcity.agent.jvm.os.name", "Windows 10")
}

Or…

fun Requirements.macXcode14() {
matches("teamcity.agent.name", "xcode14-mac")
}

Now, let’s go back to building the Web application. It’s a lot like an Android build, but there’s an extra step to consider.

The complete Web Build Implementation

// Web.kt
package Client.buildTypes

import Common.archive
import Common.macOS
import Common.unity
import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType
import jetbrains.buildServer.configs.kotlin.v2019_2.DslContext
import jetbrains.buildServer.configs.kotlin.v2019_2.ParameterDisplay

object Web : BuildType({
id("ClientWebBuild")
name = "Web"

artifactRules = """
WebGame.tgz
""".trimIndent()

params {
text(
"env.UNITY3D_INSTALL_PATH",
"/Applications/Unity/Hub/Editor/2022.3.12f1/Unity.app/Contents/MacOS/Unity",
display = ParameterDisplay.HIDDEN
)
}

vcs {
cleanCheckout = true
root(DslContext.settingsRoot)
}

steps {
unity {
name = "Build Web Unity Game"
unityPath = "%env.UNITY3D_INSTALL_PATH%"
projectPath = "MyGames"
buildTargetPlatform = "WebGL"
executeMethod = "MyGame.Editor.Build"
}
archive("MyGame/Web", "WebGame")
}


failureConditions {
executionTimeoutMin = 180
}

requirements {
macOS()
}
})

After building the project, we’ll receive a set of HTML, CSS, and JS files — and let’s say we want to archive all this and put them in artifacts.

To do this, we’ll create BuildSteps.kt where we’ll define the archive function, which takes two arguments as input: folderPath and archiveName. With that, we’ve created a universal function that can be reused in other builds:

// BuildSteps.kt
package Common

import jetbrains.buildServer.configs.kotlin.v2019_2.BuildSteps
import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.ScriptBuildStep
import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script

fun BuildSteps.webArchive(folderPath: String, archiveName: String, init: ScriptBuildStep.() -> Unit = {}) {
script {
name = "Archiving application files"
scriptContent = """
tar -czf ${archiveName}.tgz ${folderPath}
""".trimIndent()
init(this)
}
}

So, we’ve got a full Unity game build using the Teamcity CI system and Kotlin DSL. Of course, we’ve just shown a small part of what is possible when using Teamcity and Kotlin DSL; but that will be a story for another day!

--

--

MY.GAMES
MY.GAMES

MY.GAMES is a leading European publisher and developer with over one billion registered users worldwide, headquartered in Amsterdam.