Let's Make a Game Engine Work Inside Compose Multiplatform

No SurfaceView. No OpenGL. No Godot. A 2D game loop running on withFrameNanos, a fixed timestep simulation, and a redraw path that never recomposes — shipping to Android and Desktop from one commonMain.

Let's Make a Game Engine Work Inside Compose Multiplatform

I'm rebuilding DOGWALK — Blender Studio's short game about walking a dog — as a 2D game in Compose Multiplatform.

Not in Godot. Not in Unity. Not on a SurfaceView with OpenGL underneath. In plain Compose, with engine/ and game/ living entirely in commonMain, no expect/actual, no platform imports, shipping to Android and Desktop from the same source.

The obvious question is why, and the honest answer is that I wanted to know whether Compose could actually take it. It turns out it can — but only if you get three specific things right.

The game loop running on placeholder blobs: brown dog, red kid on a leash, green trees
Everything works except the art. The circle is the leash radius; the dark line is the dog's 8-direction facing.

That screenshot is the whole game loop working. A brown blob you drag to move, a red blob dragged behind it on a leash, green blobs you pass in front of and behind. It looks like nothing. It's the most important build in the project.

1. withFrameNanos is the game loop#

Every game needs a heartbeat. On Android you'd normally reach for Choreographer, a render thread, or a SurfaceView with its own draw loop.

Compose already has one:

Kotlin
LaunchedEffect(game, paused) {
    if (paused) return@LaunchedEffect
    var lastNanos = withFrameNanos { it }
    var accumulator = 0f

    while (coroutineContext.isActive) {
        withFrameNanos { now ->
            val frameTime = ((now - lastNanos) / 1_000_000_000.0)
                .toFloat()
                .coerceAtMost(MAX_FRAME_TIME)
            lastNanos = now
            accumulator += frameTime
            // ...
        }
    }
}

withFrameNanos suspends until the next frame and hands you the frame time. A LaunchedEffect looping on it gives you a vsync-aligned callback, cancelled automatically when the composable leaves the tree. No thread to manage, no lifecycle to wire up.

That coerceAtMost(MAX_FRAME_TIME) matters more than it looks. If the app is backgrounded or the GC stalls for two seconds, an unclamped accumulator would try to catch up two seconds of simulation in one frame, which takes longer than a frame, which grows the backlog further. That's the classic death spiral. Clamping at 0.25s means a hitch stays a hitch instead of becoming a freeze.

2. Simulation and rendering are different jobs#

The Game contract has two methods, and the split is the whole point:

Kotlin
interface Game {
    fun update(dt: Float, input: InputState)   // fixed 60Hz, deterministic
    fun render(scope: DrawScope, alpha: Float) // once per display frame
}

update runs on a fixed timestep. Same dt every time, so physics and movement are deterministic and reproducible — a leash constraint that's stable at 60Hz stays stable regardless of what the display is doing.

render runs once per display frame with an alpha between 0 and 1, interpolating between the last two simulation states.

Kotlin
var steps = 0
while (accumulator >= FIXED_DT && steps < 8) {
    input.beginStep()
    game.update(FIXED_DT, input)
    accumulator -= FIXED_DT
    steps++
}

On a 120Hz phone the simulation still ticks 60 times a second, but you get 120 interpolated frames of smooth motion. Tie movement to frame time instead and your game literally behaves differently on different hardware — the dog moves further per second on a 120Hz device than a 60Hz one. That's not a performance bug, it's a correctness bug.

3. Redraw without recomposition#

This is the one that decides whether the whole approach is viable or a toy.

The naive version drives the frame counter through state that composition reads, which means every frame recomposes. At 60fps, that's 60 recompositions a second, each allocating, each running layout. It works, and it's awful.

Instead:

Kotlin
// Read inside the draw lambda to invalidate only the draw phase each frame.
var frameTick by remember { mutableIntStateOf(0) }

Canvas(modifier) {
    frameTick        // <- the read that matters, inside draw
    game.render(this, alphaHolder[0])
}

frameTick is snapshot state that is only ever read inside the draw lambda. Compose tracks where a state read happens, so changing it invalidates the draw phase alone — no recomposition, no relayout, effectively nothing allocated in a steady-state frame.

If you ever find yourself reading game state during composition instead of during draw, you've lost it. That single distinction is what makes Compose a plausible 2D renderer.

Blobs first, art later#

Nothing in that screenshot is art. That's deliberate.

Placeholder rendering is a Placeholder.kt that draws coloured shapes, and it let me get the leash constraint, the Y-sorted depth ordering, the camera follow and the 8-direction facing all working before a single sprite existed. When the art pipeline lands, it swaps in behind an Atlas and nothing else changes.

The other thing that made this pleasant: developing on Desktop.

Shell
./gradlew :composeApp:run

About two seconds to a running window, against a Gradle install plus emulator deploy for Android. When you're tuning how a dog feels to move — and that's a thing you tune by feel, twenty times in a row — that difference decides whether the project survives its first week.

What's next#

The art is the interesting part, and it's a strange pipeline: DOGWALK is a 3D Blender project, so the plan is rendering each character from 8 directions orthographically, trimming and packing the results into a sprite atlas, and sampling that at runtime.

It works because of what DOGWALK's art already is — top-down, papercraft (scanned real paper, flat and high-contrast rather than PBR-realistic), and animated at a deliberately low frame rate. Ten frames per animation instead of sixty means rendering all eight directions is cheap, and the stepped look is the intended style rather than a compromise.

That's part 2.

DOGWALK's source bundle is CC BY 4.0 and the scripts are MIT, so derivatives and commercial use are allowed — with attribution: Assets derived from DOGWALK — (CC) Blender Foundation | studio.blender.org/dogwalk. Worth reading the licence yourself before you build on someone's work.

The repo is public and follows along as I go: github.com/johnkenedy/DogWalk

Until then, let's make this work.

Keep reading

More breakdowns

System Design

Let's Make System Design Work: What It Actually Is

System design is drawing how a system is — architecture, components, modules, interfaces and flows. Here's what you're really being assessed on, and why half the scorecard has nothing to do with the diagram.

· 5 min read
Android Architecture

Let's Make SOLID Work: The Principles and Patterns Behind Android Architecture

AsyncTask died, Fragments were rewritten twice, XML gave way to Compose. What survived every migration was the shape of the code underneath. Here are the five principles and nine patterns that decide it.

· 6 min read
Android Architecture

Let's Make ViewModels Work: A Look Inside ViewModel.kt

Let's break down the actual source of ViewModel.kt — why it's abstract, how it survives rotation, and how viewModelScope quietly saves you from leaking network calls.

· 3 min read