Developing Games for Android and iOS with Kotlin in Jetpack Compose

Ronny Remesnik
Autodesk TLV
Published in
10 min readAug 6, 2023

Jetpack Compose is Android’s recommended modern toolkit for writing and building native UI. Using compose we can bring our app to life using less code, and very quickly, but what about games ?

Recently, Jetbrains announced that compose Multiplatform will be able to target iOS for the first time (Desktop had already been supported for a while) which great news, since now we share the UI between our Android and iOS, which means that we can try to create our game and share the whole code between two platforms, and write it all using Compose.
Let’s see how this can be achieved.

Our first step will be to create new KMP (KMM name is deprecated) and run it to make sure we have a good starting point for running an Android and iOS app.

Our second step will be defining compose Multiplatform libraries support for the KMP, project since they aren’t a part of the basic template, and we need to manually add these libraries into our Gradle settings.
Also we will create a shared composable function that will be shared for Android and iOS.

Finally in the third step we will create a very simple game engine in compose in Kotlin(which is based on the wonderful tutorial of Sebastian Aigner almost two years ago — creating Asteroid game ! Check it out for more info)

First step — Creating KMP Project

Download latest Android Studio and open a new project

Select Kotlin Multiplatform App

Select Kotlin Multiplatform App and this template will generate all the needed files in order to run our app on Android and iOS.

Write your app’s name and package name

Write your app and package names and select the Minimum SDK in order to target your audience. I choose Android 8.1 since it targets most of the devices. Finally leave the build configuration as is, and click Next.0

Write your Android and iOS project names

In the next screen choose the Android and iOS project names and click Finish.

Once finished is clicked, AS will sync all the needed files (which can take a while, since on the first time it needs to download all the packages for KMP)

When finished, you can test and run that everything works OK, in AS click Play and for iOS — open the iOS folder, click on the iosBallApp.xcodeproj and open it using Xcode.

Open with Xcode

Inside Xcode, click Play and see the results :

Android and iOS apps running with KMP support

Second step — Define Jetpack Compose Multiplatform support

Next steps are needed in order to use the compose libraries inside the shared code, this will require manual settings inside the settings and Gradle files. Let’s jump into it.

Open gradle.properies file and add these lines :

kotlin.version=1.8.20
agp.version=8.0.1
compose.version=1.4.0

org.jetbrains.compose.experimental.uikit.enabled=true
kotlin.native.cacheKind=true

Next, open Project build.gradle.kts and replace the lines the template created with the following lines :

  kotlin("multiplatform").apply(false)
id("com.android.application").apply(false)
id("com.android.library").apply(false)
id("org.jetbrains.compose").apply(false)

We just removed the constant versions and we added compose library plugin.

For android and shared build.gradle.kts , add also the plugin of compose as one of the plugins.

id("org.jetbrains.compose")

Inside the shared build.gradle.kts scroll down to the code where there are list of iOS and add isStatic = true so your code will look like this :

listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "shared"
isStatic = true
}
}

Next, we will add the compose dependencies to the common main module. Make sure your commonMain variable will have these dependenices :

val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.components.resources)
}
}

it’s ok that they are still marked in red color, since we didn’t sync the project yet. Let’s wait with the sync for a little bit.

Finally, let’s open settings.gradle.kts and add compose repository so all the dependencies can be resolved :

Add this inside Plugin management repositories, as well as inside the Resolution management :

maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")

Then, inside the Plugin management code, add this block of code:

    plugins {
val kotlinVersion = extra["kotlin.version"] as String
val agpVersion = extra["agp.version"] as String
val composeVersion = extra["compose.version"] as String

kotlin("jvm").version(kotlinVersion)
kotlin("multiplatform").version(kotlinVersion)
kotlin("android").version(kotlinVersion)

id("com.android.application").version(agpVersion)
id("com.android.library").version(agpVersion)

id("org.jetbrains.compose").version(composeVersion)
}

This code takes the configuration we defined and applies the version to the plugins, so in the future we can easily change the versions.

Now we can click the sync Gradle with project files and hope that everything will sync correctly.

Now we will add shared composable functions that will be in use both for Android and for iOS.

Open commonMain package and add inside Kotlin a new file called App

Add App file tha will contain shared composable code

Add some composable content into that file, for example:

@Composable
fun App() {
var count by remember { mutableStateOf(0) }

Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Button(
onClick = { count++ }
) {
Text("Count: $count")
}
}
}

Next, we will need to use this composable function in Android and in iOS.

For android, let’s open our MainActivity and add the new composable function instead of the current code

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
App()
}
}
}
}

For iOS we need to do some additional steps.

First, let’s open our iosMain package inside AS and add a new file called App and add this function inside the file :

fun MainViewController() = ComposeUIViewController {
App()
}

This function creates an iOS style view controller that will be a special composable view controller since we are providing our shared composable function — App()

Second, we need to create a new file

Creating a new Composable view for iOS

Lets call it ComposableView swift file and add this content inside the file

import Foundation
import SwiftUI
import shared

struct ComposeView: UIViewControllerRepresentable {
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
}

func makeUIViewController(context: Context) -> some UIViewController {
AppKt.MainViewController()
}

}

We can see that we are statically calling our previous method MainViewController (from Kotlin) that will hold our composable shared code.

By running the code after the changes, we will see a simple composable views both on Android and on iOS written from same shared code.

Android and iOS are running the same composable code written once in compose.

This step is described very well by Philipp Lackner in his great tutorial video, you can check it out here

Third step — writing a small game engine.

Inside a commonMain package let’s create a Game class and MainGame file.

enum class GameState {
STOPPED, RUNNING
}

class Game {
var prevTime = 0L
var width by mutableStateOf(0.dp)
var height by mutableStateOf(0.dp)

var gameState by mutableStateOf(GameState.STOPPED)
var totalTime by mutableStateOf(0L)


fun startGame() {
gameState = GameState.RUNNING
totalTime = 0L
}

fun endGame() {
gameState = GameState.STOPPED
}

fun update(time: Long) {
val delta = time - prevTime
prevTime = time

if (gameState == GameState.STOPPED) return

totalTime += delta
}
}

and MainGame composable with separate file.

@Composable
fun MainGame() {
val game = remember { Game() }
val density = LocalDensity.current
LaunchedEffect(Unit) {
while (true) {
withFrameNanos {
game.update(it)
}
}
}

Column(modifier = Modifier.background(Color(51, 153, 255)).fillMaxHeight().fillMaxWidth()) {
Row(modifier = Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) {
Button({
if (game.gameState == GameState.STOPPED)
game.startGame()
else
game.endGame()
}) {
Text(if (game.gameState == GameState.STOPPED) "Play" else "Stop")
}

Text(
"${(game.totalTime / 1E8).roundToInt() / 10f} seconds.",
modifier = Modifier.align(Alignment.CenterVertically).padding(horizontal = 16.dp),
color = Color.White
)

}

Box(modifier = Modifier
.background(Color.Black)
.fillMaxWidth()
.fillMaxHeight()
.clipToBounds()
.onSizeChanged {
with(density) {
game.width = it.width.toDp()
game.height = it.height.toDp()
}
}) {

}
}
}

We can see that we create our Game class and every time there is an update we call game.update(it) This is the heart of the engine, where update called with some times, and we can know how much time took to draw last frame.

if we compile and play our code, we will see a running time.

Running compose engine code — shows the running time

This maybe isn’t much but it’s actually a template for a very basic game, we just don’t have any game objects, and math and nothing but that’s can be easily added as well.

Let’s add some math library so we can use some basic math vectors.
Add this dependency to the commonMain dependencies, this allow us to use vectors with Float2 (by RomainGuy)

implementation ("dev.romainguy:kotlin-math:1.5.3")

Now let’s create some basic GameObject and add a basic functionality to it :

abstract class GameObject(
speed: Float = 0.0F,
angle: Float = 0.0F,
position: Float2 = Float2()
) {
companion object {
val UNIT_X = Float2(1.0f, 0.0f)
}

var speed by mutableStateOf(speed)
var angle by mutableStateOf(angle)
var position by mutableStateOf(position)
var movementVector
get() = (UNIT_X.times(speed)).rotate(angle)
set(value) {
speed = length(value)
angle = value.angle()
}
abstract val size: Float // Diameter

open fun update(realDelta: Float, game: Game) {
val obj = this
val velocity = movementVector.times(realDelta)
obj.position = obj.position.plus(velocity)
}
}

Then let’s create a BallData class that will be inherited from this game object :

class BallData(
ballSize: Float = 20f,
val color: Color = Color.Red,
var isEnabled: Boolean = true
) :
GameObject() {
override var size: Float = ballSize
override fun update(realDelta: Float, game: Game) {

if (!isEnabled) return

super.update(realDelta, game)

if (xOffset > game.width - size.dp || xOffset < 0.dp) {
movementVector = movementVector.times(Float2(-1f, 1f))
if (xOffset < 0.dp) position.x = 0f
if (xOffset > game.width - size.dp) position.x = game.width.value - size
} else if (yOffset > game.height - size.dp || yOffset < 0.dp) {
movementVector = movementVector.times(Float2(1f, -1f))

if (yOffset < 0.dp) position.y = 0f
if (yOffset > game.height - size.dp) position.y = game.height.value - size
}
}

}

We handle the logic of the ball inside the update method, once the ball hits the walls, we change the vector direction.

Let’s add some code to our Game class that will handle the balls in the game.

class Game {
var prevTime = 0L

var gameObjects = mutableStateListOf<GameObject>()
var gameState by mutableStateOf(GameState.STOPPED)
var gameStatus by mutableStateOf("Let's play!")
var totalTime by mutableStateOf(0L)


fun startGame() {
gameObjects.clear()

repeat(10) {
val ball = BallData(
ballSize = random(25, 45),
color = Color(
red = Random.nextFloat(),
green = Random.nextFloat(),
blue = Random.nextFloat()
)
)

ball.position = Float2()//Float2(width.value / 2.0F, height.value / 2.0F)
ball.movementVector = Float2(1f, 0f)
ball.speed = random(0, 8) + 16f
ball.angle = random(0, 15) + 30f

gameObjects.add(ball)
}

gameState = GameState.RUNNING
gameStatus = "Good luck!"
totalTime = 0L
}

fun update(time: Long) {
val delta = time - prevTime
val floatDelta = (delta / 1E8).toFloat()
prevTime = time

if (gameState == GameState.STOPPED) return

for (gameObject in gameObjects) {
gameObject.update(floatDelta, this)
}

val allDisabled = gameObjects.filterIsInstance<BallData>().all {
!it.isEnabled
}

if (allDisabled && gameState == GameState.RUNNING) {
winGame()
}

totalTime += delta
}

fun endGame() {
gameObjects.clear()
gameState = GameState.STOPPED
gameStatus = "Better luck next time!"
}

fun winGame() {
gameState = GameState.STOPPED
gameStatus = "Congratulations!"
}

var width by mutableStateOf(0.dp)
var height by mutableStateOf(0.dp)
}

We are creating 10 random balls and add them to game objects array, when game starts. Then for every update of the game, we call to the update for each ball, so each ball can move.
Once game ended, we clear all the objects.
This is the main game loop.

In order for code to work we are using some extension methods, create file called GameExtensions.kt and add this code

val GameObject.xOffset: Dp get() = position.x.dp
val GameObject.yOffset: Dp get() = position.y.dp

inline val Float.asRadians: Float get() = this * 0.017453292f
inline val Float.asDegrees: Float get() = this * 57.29578f

fun Float2.angle(): Float {
val rawAngle = atan2(y = this.y, x = this.x)
return ((rawAngle / PI) * 180F).toFloat()
}

fun Float2.rotate(degrees: Float, origin: Float2 = Float2()): Float2 {
val p = this - origin
val a = degrees.asRadians

val w = Float2(
p.x * cos(a) - p.y * sin(a),
p.y * cos(a) + p.x * sin(a)
)

return w + origin
}

fun random(min: Int, max: Int): Float {
require(min < max) { "Invalid range [$min, $max]" }
return min + Random.nextFloat() * (max - min)
}

Now let’s create a representation of a ball in compose :

@Composable
fun Ball(ballData: BallData) {
val ballSize = ballData.size.dp
Box(
Modifier
.offset(ballData.xOffset, ballData.yOffset)
.size(ballSize)
.clip(CircleShape)
.background(ballData.color)
.clickable { ballData.isEnabled = !ballData.isEnabled }
)
}

and finally let’s use this inside our main game composable :

@Composable
fun MainGame() {
val game = remember { Game() }
val density = LocalDensity.current
LaunchedEffect(Unit) {
while (true) {
withFrameNanos {
game.update(it)
}
}
}

Column(modifier = Modifier.background(Color(51, 153, 255)).fillMaxHeight().fillMaxWidth()) {
Row(modifier = Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) {
Button({
if (game.gameState == GameState.STOPPED)
game.startGame()
else
game.endGame()
}) {
Text(if(game.gameState == GameState.STOPPED) "Play" else "Stop")
}
Text(
game.gameStatus,
modifier = Modifier.align(Alignment.CenterVertically).padding(horizontal = 16.dp),
color = Color.White
)

Text(
"${(game.totalTime / 1E8).roundToInt() / 10f} seconds.",
modifier = Modifier.align(Alignment.CenterVertically).padding(horizontal = 16.dp),
color = Color.White
)

}


Box(modifier = Modifier
.background(Color.DarkGray)
.padding(50.dp)
.border(BorderStroke(2.dp, Color.Red))
.fillMaxWidth()
.fillMaxHeight()
.clipToBounds()
) {

Box(modifier = Modifier
.background(Color.Black)
.fillMaxWidth()
.fillMaxHeight()
.clipToBounds()
.onSizeChanged {
with(density) {
game.width = it.width.toDp()
game.height = it.height.toDp()
}
}) {
game.gameObjects.forEach {
when (it) {
is BallData -> Ball(it)
}
}
}
}
}
}

These lines

game.gameObjects.forEach {
when (it) {
is BallData -> Ball(it)
}
}

means that we are “drawing” the composable ball, and each ball will move by itself (once it’s position will change)

We also enable / disable the ball once it’s clicked, so for our game to end we count the number of disabled balls and when all balls are disabled, we win.

val allDisabled = gameObjects.filterIsInstance<BallData>().all {
!it.isEnabled
}

if (allDisabled && gameState == GameState.RUNNING) {
winGame()
}

Let’s run the games and we will get :

Balls compose game in Android & iOS

I hope that these examples can help you get started with developing your own game using Jetpack Compose Multiplatform with Kotlin.
Please don’t hesitate to contact me with any questions.

Full source code :

Happy Koding !

--

--