What It's Like Making a Game with Compose Multiplatform
I built an idle game with Compose Multiplatform. Here's why Kotlin + Compose is a surprisingly good (and frustrating) choice for UI-heavy games.
I built an idle game with Compose Multiplatform. Let me tell you about it.
First, a disclaimer:
- I care deeply about DX.
- Don’t come at me with “just make your game, players don’t care about code.” Yes, players don’t care about code, but I do. If that’s your take, you’re not who I’m talking to.
- Don’t come at me with “AI will solve everything, DX doesn’t matter.” Sure, AI will solve everything — including automatically depositing 100000$ into your bank account. You just lie back and relax.
Why?
First, if you’re not familiar with Compose Multiplatform, let me break it down. It’s:
- A Kotlin UI library
- Originally, Compose was Android’s native UI framework; then JetBrains released the Multiplatform version, bringing Compose to every platform
A UI library — so why use it to make a game?
The answer is simple: because my game is nothing but UI.
The harsh truth: 100% of game engines have terrible UI solutions. You might say “Godot is great, Control nodes are awesome!”
I used C# + Godot for a long time, then I gave up. Let me tell you exactly where Godot’s UI falls apart:
- Control nodes basically force you to use the editor. If you want to build UI purely in code, you’re coding like it’s 2000.
- Control nodes are fundamentally incompatible with animation — layout constraints and transitions are at war. The only workaround is wrapping things in another Control, and then you lose your layout constraints.
- Control nodes lack modern features: reactivity, real componentization — none of it.
I’m not singling out Godot. Godot already has the best UI among game engines — and that’s exactly the problem: 100% of game engines have awful UI. Godot is just the least awful.
If you still think Control nodes are the ultimate UI solution, it only means one thing: you’ve never used a real UI framework. Your job is to go learn, not to email me angry rants.
Compose is Great
Let’s look at how Compose describes UI. A simple Clicker:
@Composable
fun Clicker() {
var count by remember { mutableStateOf(0) }
Column {
Text(
text = count.toString(),
fontSize = 32.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { count++ }) {
Text(text = "Click!", fontSize = 18.sp)
}
if (count >= 10) {
Text(text = "More than 10!")
}
}
}
This code speaks for itself. What I love most:
You write logic directly in Kotlin, and logic and UI coexist naturally. That if (count >= 10) is plain Kotlin — no special syntax, no mental overhead.
Now let’s look at the web tech everyone uses — the same Clicker in React:
function Clicker() {
const [count, setCount] = useState(0)
return (
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 32, fontWeight: "bold" }}>{count}</span>
<div style={{ height: 24 }} />
<button
onClick={() => setCount(count + 1)}
style={{ fontSize: 18 }}
>
Click!
</button>
{count >= 10 && <span>More than 10!</span>}
</div>
)
}
See the difference? JSX is always a patch — you’re writing JavaScript, but you also have to piece together HTML, and resort to tricks like {count >= 10 && ...} for conditional rendering. In Compose, it’s just a plain if.
The gap isn’t night and day in a tiny example. But as your UI grows more complex and logic-heavy, Compose’s “UI is code” feeling becomes stronger, while JSX’s disjointedness becomes more grating.
Those were the “modern UI approaches.” Now let’s see what Godot offers. Basically two options:
# Option 1: Build UI in the editor first
@onready var addCountButton = $"AddCountButton"
var count = 0
func _ready():
addCountButton.button_pressed.connect(addCount)
func addCount():
count += 1
countLabel.text = str(count)
Option 2 is building everything in code — but nobody does it in practice because it’s a pain:
# Option 2: Pure code
extends Control
var count = 0
var count_label: Label
func _ready():
var vbox = VBoxContainer.new()
add_child(vbox)
count_label = Label.new()
count_label.text = "0"
count_label.add_theme_font_size_override("font_size", 32)
vbox.add_child(count_label)
var spacer = Control.new()
spacer.custom_minimum_size = Vector2(0, 24)
vbox.add_child(spacer)
var button = Button.new()
button.text = "Click!"
button.pressed.connect(_on_click)
vbox.add_child(button)
func _on_click():
count += 1
count_label.text = str(count)
You see the difference. In Godot, you have to manage everything manually — that’s not “control,” it’s just more boilerplate.
Kotlin
Kotlin is an absolute beast. In my opinion, it’s one of the best-designed programming languages out there — expressive and genuinely pleasant to use. It shows in all the little details.
Take trailing lambdas:
// Kotlin
processor.doSomething { it.property = "badass" }
// TypeScript
processor.doSomething((x) => {
x.property = "badass"
})
Concretely, trailing lambdas save you from naming an unnecessary variable and from writing extra parentheses and arrow symbols. It’s a small thing, but you write this kind of code constantly — if you actually type code by hand, the difference is enormous.
And my favorite thing: a useful standard library.
Kotlin ships a ton of practical little functions that drastically improve DX and expressiveness:
something?.let { it.doWork() } // execute only if non-null
aMap.getOrPut("key") { expensiveDefault() } // get or insert default
list.groupBy { it.category } // one-liner grouping
sequence.chunked(10) { batch -> ... } // batch processing
Each of these is tiny, but together — they massively lift the dev experience.
Kotlin itself is also well-suited for gamedev — at least more than GDScript. (haha)
Drawbacks
Compose is far from flawless. In fact, its drawbacks are significant and numerous.
This article covers Compose v1.10.0
Localization
Android’s ecosystem has been using strings.xml for years — battle-tested, with everything we need. Compose Multiplatform inherits this system.
Unfortunately, while strings.xml exists, there’s no changeLanguage() API.
That’s right — in Compose, you can’t switch languages at runtime without hacks. Your only options are: abandon strings.xml, or tell your users to change their system language.
I chose to ditch strings.xml and use a Kotlin gettext library instead. It works, but it shouldn’t be a problem you have to work around.
Gradle
I have to talk about Gradle, because it’s a pure nightmare.
- Complex: Gradle’s DSL has tons of identically-named configs in different scopes, and I still can’t figure out which one I’m supposed to use.
- More complex: I eventually gave up writing Pre/Post Build scripts in Gradle — they simply didn’t work. I wrote a
tasks.tsin bun + TypeScript instead. - Slow: Gradle is painfully slow, and the Kotlin compiler is painfully slow too. My project has ~30k lines of Kotlin; a clean build takes nearly 3 minutes. As a modern developer, that was my first taste of “waiting for compilation.”
Gradle is hard to work around because it’s the de facto standard of the entire JVM ecosystem, and Compose depends on Gradle as much as it depends on the Kotlin compiler. You use Compose Multiplatform, you get Gradle — no choice.
Yes, I know JetBrains created something called Amper to sidestep Gradle’s complexity. But it still runs Gradle under the hood, and honestly I don’t see the benefit — Amper uses YAML, so you still can’t write build scripts.
Audio Playback
Compose doesn’t provide a built-in audio playback solution — you have to roll your own.
On Desktop, I had to pull in com.googlecode.soundlibs:vorbisspi just to play ogg files.
It feels terrible — something that should be built-in becomes you digging through Maven Central for a library that hasn’t been updated in a decade.
Distribution
Compose distributes well on Android — it is the native solution, after all. But on Desktop, it’s a rough story.
Specifically: your app has to bundle the JVM, then consume at least 200MB of memory at startup. The web ecosystem already has lightweight native distribution solutions like Tauri, while we get something heavier than Electron.
And there’s no hope of escaping the JVM in the foreseeable future.
PS. On iOS, you can indeed ship without the JVM, since Kotlin/Native compiles directly to native code.
Conclusion
Overall, Compose is an excellent UI technology, and Kotlin is a great language — both deserve praise for their developer experience.
But Gradle and the JVM severely drag that experience down.
If Compose and Kotlin can grow their own ecosystem and stop mandating the JVM, it would be near-perfect. For now, it’s a good fit if you’re already in the JVM/Android ecosystem — or, like me, your game is fundamentally a UI application and you don’t want to use web tech.
Important: I still recommend using web tech. Compose’s strengths are its API design and Kotlin.