One Kotlin codebase, five platforms, 60 fps: building a 3D game with KMP and CMP
Fixes for audio stutter, hidden Android frame drops, camera jitter, split screen, controllers and online play, measured on real devices, and the open-source library we pulled out of our game.
We make BoatBrawl, an arcade boat-combat game built with Kotlin Multiplatform (KMP) and Compose Multiplatform (CMP). One KMP codebase (game rules, physics, netcode, audio, input and our own OpenGL and Metal renderer) runs on iPhone, Android, Mac, Windows and the web, and the menus and screens are CMP. It has online matches and couch play, and we are entering it in RevenueCat's Shipaton. Sharing the code turned out to be the easy part. Making the game feel smooth on every one of those platforms took most of our time, and almost none of the problems had anything to do with boats.
This post is the list we wish we had at the start: what went wrong, how we found it, and the code that fixed it. If you are building any real-time 3D game or interactive scene with KMP and CMP, most of it applies to you.
The library from this post: Joyframe on GitHub · Maven Central
- Measure frame intervals in the loop that advances your game, in release builds.
- Never touch native audio from the game loop. One background consumer owns every audio call.
- On Android, let the GL thread run on its own instead of pumping it from composition.
- Render phones below native resolution: 0.8 scale on Android, a 1920-pixel cap on iPhone.
- Damp your follow camera in render time; cut it on respawn.
- Split screen belongs in one GPU surface on every platform: scissored viewports on OpenGL and WebGL, one pass with a shadow map per pane on Metal.
- Read controllers by button position and keep each player in their seat.
- Online: send game state over an unordered WebRTC channel, never let one slow client block the rest, and size the jitter buffer from measured jitter.
- Use the web build as your fastest playtest loop, and version your protocol for apps already installed.
Which platforms each fix covers. Android, the desktop apps (Mac, Windows, Linux) and the browser draw with OpenGL or WebGL; only iPhone and iPad draw with Metal.
| Fix | Android | iPhone | Mac · Windows · Linux | Web |
|---|---|---|---|---|
| 0. Measure frame intervals | ✓ | ✓ | ✓ | ✓ |
| 1. Audio off the game loop | ✓ | ✓ | ✓ | ✓ Web Audio never blocks |
| 2. A render loop that fits each platform | ✓ own GL thread, 0.8 scale, 60 Hz | ✓ Metal display callback | ✓ Compose frame clock | ✓ requestAnimationFrame |
| 3. Resolution cap | ✓ with the 0.8 scale | ✓ | full resolution | ✓ |
| 4. Chase camera, fixed step | ✓ | ✓ | ✓ | ✓ |
| 5. Split screen in one surface | ✓ OpenGL | ✓ Metal | ✓ OpenGL | ✓ WebGL |
| 6. Controllers and seats | ✓ | ✓ | ✓ | ✓ |
| 7. Touch controls and tilt | ✓ | ✓ | – | ✓ on phones |
| 8. Online play | ✓ | ✓ | ✓ | ✓ |
0. Measure the right thing
"It feels laggy" is not actionable, and average FPS hides exactly the frames players notice. What worked for us was recording the interval between frames and reporting four numbers every two seconds:
- FPS, for the headline.
- Late frames: the share of intervals longer than 25 ms, one and a half frames at 60 Hz.
- p95 and p99 of the interval, to see how bad the bad frames are.
- Worst interval, because one 350 ms freeze ruins a race.
Two rules made the numbers trustworthy. First, measure release builds: debug builds are dominated by JIT and debuggable overhead and will not show any of this. Second, measure the loop that advances your game, not the display. We will see why in fix 2: Android can present a perfect 60 fps while your game state is stuck.
val pacing = FramePacing() // from Joyframe; ~60 lines if you write your own
pacing.record(frameNanos) // every frame, in the loop that advances the game
Text(pacing.report.toString()) // "59.8 fps · 0.4% late · p99 16.7 ms · worst 33.3 ms"
1. Audio must never run on your game loop
BoatBrawl felt rough on an iPhone 15 Plus, but only sometimes. We ran the same scripted 100-second match three times: sound on, muted, sound on again.
| Sound on | Muted | Sound on again | |
|---|---|---|---|
| Frames per second | 53.5 | 59.5 | 52.9 |
| Frames later than 25 ms | 8.8% | 1.0% | 9.6% |
| Game update, p95 | 21.5 ms | 4.1 ms | 22.5 ms |
| Worst frame | 100 ms | 50 ms | 133 ms |
Muting fixed it without lowering resolution, boat count or graphics quality, and GPU time stayed below the frame budget on average. The main suspect was on the CPU: our audio code updated native players (volume, playback rate, playback state) from the game loop, every frame, on the main thread. The measurements could not say which single call was to blame, so we stopped making any of them there.
The fix was structural. One background consumer owns every native audio call; the game only drops small requests into a bounded queue and never waits:
private class Cue(val id: SoundId, val volume: Float, val epoch: Int) {
val queuedAt = TimeSource.Monotonic.markNow()
}
// Four slots; when full, the oldest request is dropped instead of blocking the caller.
private val pending = Channel<Cue>(4, BufferOverflow.DROP_OLDEST)
private val epoch = AtomicInt(0)
fun play(id: SoundId, volume: Float) { // called from the game loop
pending.trySend(Cue(id, volume, epoch.load())) // never suspends, never touches native audio
}
fun stop() { epoch.addAndFetch(1) } // mute/pause invalidates everything still queued
private val worker = scope.launch(Dispatchers.Default) {
mixer.preload() // decode and open voices off the game thread
for (cue in pending) {
if (cue.epoch != epoch.load()) continue // muted since it was queued
if (cue.queuedAt.elapsedNow() > 100.milliseconds) continue // late sound is worse than none
mixer.play(cue.id, cue.volume) // the only place native audio is called
}
}
Three details matter as much as the queue. Sounds older than 100 ms are dropped, so a backlog never plays as a burst after a hitch. Mute and pause bump an epoch, so nothing stale plays on resume. And loops such as the engine start and stop on state changes, with hysteresis, rather than getting per-frame volume or rate updates.
After the change, sound on and sound off measured the same on that iPhone, in two manually played matches: 58.8 vs 59.1 fps, and a game-update p95 of 3.44 vs 3.46 ms. On the web, Web Audio is already non-blocking, so the browser version calls it directly.
2. Android said 60 fps while the game ran at 39
On a Snapdragon 730G phone (Adreno 618, 1080×2400) our game ran at 39 to 60 fps with single frames as long as 350 ms, and the system reported nothing wrong. The GL thread kept re-presenting the last frame at a steady 60 while the game state was stuck, so every display-side metric looked perfect. Only a probe in the game loop showed the truth. Four changes, in the order they mattered:
Unchain the render thread
We drove GLSurfaceView from Compose: build the frame in composition, then requestRender().
That made frame building and drawing one serial path. Each side used a little over half a frame on its own; together
they missed vsync. Letting the GL thread run continuously and draw the newest published frame let both fit:
glSurfaceView.renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
// Composition only publishes. It never pumps the renderer.
SideEffect { renderer.publish(frame) }
// GL thread: take the newest frame, waiting at most 50 ms for one, then draw.
override fun onDrawFrame(gl: GL10?) {
val frame = lock.withLock {
var remaining = 50_000_000L
while (published == taken && remaining > 0) remaining = arrived.awaitNanos(remaining)
taken = published
latest
}
backend.render(frame, width, height)
}
Publish the frame as one immutable object behind a single write, so the scene and anything drawn with it (a HUD, say) always describe the same tick.
Render at 0.8 scale
At native resolution the scene shaded 2.6 million pixels a frame and kept the GPU around 90% busy. Rendering at 0.8 and letting the compositor upscale brought it to about 70%, and on a 420 dpi panel in motion the difference is close to invisible. Resize the surface buffer, not the view, so touch input keeps its full coordinates:
override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
super.onSizeChanged(w, h, oldW, oldH)
holder.setFixedSize((w * 0.8f).roundToInt(), (h * 0.8f).roundToInt())
}
Cheaper shadows
A 3×3 PCF shadow filter cost nine depth comparisons per shaded pixel, about 23 million a frame. Four diagonal taps, each already bilinear, cover most of the same footprint. Shrinking the shadow map did nothing: the cost scaled with screen pixels, not map size.
Ask for a clock the phone can hold
The governor boosted past the thermal envelope and fell back, and each fallback landed a late frame. Sustained performance mode halved the leftover hitches:
val power = getSystemService(PowerManager::class.java)
if (power.isSustainedPerformanceModeSupported) window.setSustainedPerformanceMode(true)
Result: 47 of 50 two-second windows at 60.05 fps, worst frame 17 ms, the other three with a single dropped frame each.
One more Android fix came later. On 120 Hz panels nothing asked for 60, so simulation, composition and GL ran twice as often on GPUs budgeted for 60. Phones heated, throttled and presented on uneven 8/17/25 ms intervals. Asking the window for the panel's own 60 Hz mode halved the work and evened the intervals:
val mode = display.supportedModes
.filter { it.physicalWidth == display.mode.physicalWidth && it.physicalHeight == display.mode.physicalHeight }
.minByOrNull { abs(it.refreshRate - 60f) }
?.takeIf { abs(it.refreshRate - 60f) < 1f }
if (mode != null) window.attributes = window.attributes.apply { preferredDisplayModeId = mode.modeId }
Every platform got its own answer
There is no single right render loop; each platform needed its own. On desktop we made the opposite choice to
Android on purpose: repaints follow Compose's frame clock, so the 3D scene and the HUD always describe the same
simulation tick. An earlier renderer ran its own pump, and a stray recomposition could multiply the frame rate. In the
browser, requestAnimationFrame drives WebGL directly.
The same idea on iPhone
iOS had its own version of the chained loop: building the scene waited for a full Compose recomposition. We moved the whole frame into Metal's display callback. It samples the controls, advances the game and its prediction, builds the scene and renders it, with no recomposition in between. The Compose HUD now redraws 15 times a second, except that health, weapon, ammo, score and match-phase changes show immediately, and the touch joystick is still sampled on every display callback. A hidden Metal view releases its surface, and only one clock ever advances the simulation, so the visible Metal view and the fallback clock can never both step a match.
3. Stop shading pixels nobody can see
An iPhone 15 Plus renders 2796×1290 natively. Capping only the 3D drawable at a 1920-pixel long edge and about
2.07 megapixels (1920×885) removed about 53% of the shaded pixels. The Compose HUD and touch coordinates stay at
native resolution, so text stays sharp. On iOS, turn off autoResizeDrawable and size the drawable
yourself; in the browser, set the canvas buffer smaller than its CSS size and let the browser scale it.
fun renderSize(width: Int, height: Int, scale: Float = 1f): Pair<Int, Int> {
val cap = min(1920.0 / max(width, height), sqrt(2_073_600.0 / (width.toDouble() * height)))
val s = min(scale.toDouble(), cap).coerceAtMost(1.0)
return (width * s).roundToInt() to (height * s).roundToInt()
}
mtkView.autoResizeDrawable = false
val (w, h) = renderSize(nativeWidth, nativeHeight)
mtkView.drawableSize = CGSizeMake(w.toDouble(), h.toDouble())
And don't draw what's off-screen
On Metal we also skip scenery outside the camera's view in the colour pass, testing each object's transformed bounds against the view frustum. Build those planes for Metal's 0-to-1 depth range, not OpenGL's −1-to-1. Objects outside the view can still cast shadows into it, so they stay in the shadow pass, and anything the vertex shader moves (water, effects, boats) is treated conservatively so nothing pops in or clips.
4. A chase camera that doesn't jitter
Gameplay runs at a fixed 60 Hz so physics behaves the same on every device, and rendering interpolates between the last two steps. But a camera snapped to the player's desired pose every frame jitters, because that pose jumps whenever a fixed step changes the heading. The fix is to keep the camera's own state and ease it toward the desired pose in render time, the aim point a little faster than the eye:
fun update(target: Vec3, forward: Vec3, speed: Float, dt: Float): Camera {
val wanted = desired(target, forward, speed) // behind, above, looking ahead
if (eye == null || dt > 0.2f) { // first frame, or resuming: cut
eye = wanted.eye; aim = wanted.aim
} else {
eye += (wanted.eye - eye) * (1 - exp(-9f * dt))
aim += (wanted.aim - aim) * (1 - exp(-12f * dt))
}
return Camera(eye + shake(), aim)
}
fun cut() { eye = null } // call after respawns and resets
Two more details make it feel right. Respawns are authored teleports, not camera moves, so cut instead of letting the camera fly across the map. And impact shake uses a cubic falloff over about 0.2 s: a hard first-frame punch that is gone before it can fight the player's aim. Widening the lens slightly with speed reads as acceleration without moving the boom.
5. Split screen in one surface
A view per player is the obvious approach, but it means a GPU context per player and uploading the same meshes and textures several times. One surface with several viewports is simpler and cheaper: the assets upload once, and each pane gets its own camera.
This works on every platform, but the two graphics APIs need slightly different code. Android, the desktop apps and the browser use OpenGL or WebGL; iPhone uses Metal.
On OpenGL and WebGL, each pane sets a viewport and a scissor rectangle, so its clear and its draws stay inside it
(remember GL counts y from the bottom):
gl.enable(GL_SCISSOR_TEST)
panes.forEachIndexed { i, pane ->
val y = surfaceHeight - pane.y - pane.height
gl.viewport(pane.x, y, pane.width, pane.height)
gl.scissor(pane.x, y, pane.width, pane.height)
renderScene(frames[i], aspect = pane.width.toFloat() / pane.height)
}
gl.disable(GL_SCISSOR_TEST)
On iPhone, Metal needs a different shape. A render pass's clear load action clears the whole attachment, not a viewport, so a pass per pane would wipe the panes drawn before it. Instead, encode every pane's shadow pass first, each into its own shadow map, then one scene pass that moves the viewport and scissor from pane to pane. Panes never overlap, so the single depth clear serves them all.
6. Controllers on four input stacks, and phones as controllers
Couch play means controllers, and Kotlin Multiplatform means four different controller APIs:
- Windows and Linux: GLFW, polled on its own daemon thread.
- macOS: Apple's GameController framework through JNA, because GLFW wants the main thread, which AppKit and AWT already own in a Compose Desktop app.
- Android: key and motion events forwarded from the Activity, kept apart by
InputDeviceid. A DualSense's rumble motors even show up as a separate device. - Browser: the Gamepad API, where a pad stays invisible until a button is pressed, and only the "standard" mapping is predictable.
Each names buttons and signs stick axes its own way, and the iOS Simulator even offers a fake controller named "Gamepad" that has to be ignored. Two decisions tamed it. First, read buttons by position (south, east, west, north) so the same code works for Xbox, PlayStation and Switch pads, and make stick Y positive-up everywhere. Second, give each controller a seat that survives other players' disconnects:
fun update(pads: List<ConnectedGamepad>) {
val present = pads.associateBy { it.id }
owners.indices.forEach { seat -> if (owners[seat] !in present) owners[seat] = null } // only the dropped seat frees
pads.forEach { pad ->
if (pad.id !in owners) owners.indexOfFirst { it == null }.takeIf { it >= 0 }?.let { owners[it] = pad.id }
}
}
In the game we also treat every button of a newly seen pad as already held, so the press that opened the lobby cannot also take a seat, and the START that begins a match cannot also pause it.
Rumble and haptics get the same care as audio. Phone haptics (UIKit feedback generators on iOS,
view haptics on Android) are queued to the UI thread and expire after 100 ms, so a late pulse never fires out of
context, and they respect the player's setting and, on Android, the system's. Controller rumble goes to the right pad:
on iOS each pad gets its own Core Haptics engine, browsers use the pad's vibrationActuator, and GLFW on
Windows and Linux has no rumble at all.
Phones as controllers let anyone join without owning a gamepad (the split-screen clip above shows one, steering by tilt). The big screen shows a QR code; anyone scans it and their phone becomes a pad, in the browser with nothing to install or in the app's controller mode. Input travels over a WebRTC data channel on the local network (unordered, no retransmits, because a late stick reading is worthless), with a WebSocket relay as the fallback and for pairing. The phone sends a stick position and running press counts for each button, so a quick tap is never lost between packets.
7. Touch controls, and tilt steering that doesn't drift
Without a controller, players choose between three ways to drive: a virtual stick, left and right buttons, or tilt, and they can rearrange the on-screen controls in a layout editor. With buttons or tilt the boat drives forward on its own unless the brake is held. On iPhone the joystick is sampled on every display callback rather than whenever Compose recomposes, so input latency does not depend on UI work.
Rolling the phone like a steering wheel sounds like a gyroscope job. It isn't: the player holds an angle, and gravity gives that angle directly, with no drift to integrate away. Take the angle of gravity in the screen plane, so it works at any pitch, from flat on a table to upright in bed, and zero it wherever the phone is held, because nobody holds a phone level:
fun steer(gravityX: Double, gravityY: Double): Float {
val roll = atan2(gravityX, gravityY).toFloat()
if (neutral.isNaN()) { neutral = roll; return 0f } // first reading is centre
var delta = roll - neutral
while (delta > PI) delta -= 2 * PI.toFloat() // shortest way round the wrap
while (delta < -PI) delta += 2 * PI.toFloat()
val raw = (-delta / Math.toRadians(26.0).toFloat()).coerceIn(-1f, 1f) // 26° is full lock
if (abs(raw) <= 0.08f) return 0f // dead zone for hand drift
return sign(raw) * (abs(raw) - 0.08f) / (1 - 0.08f) // rescale: no step at the edge
}
The same arithmetic runs on Android (TYPE_GRAVITY, falling back to the accelerometer), iOS
(CMMotionManager device motion) and phone browsers (devicemotion). Because steering is
measured relative to where you started, it does not care that iPhone and Android browsers report gravity with
opposite signs. iOS Safari only grants motion access from a tap, so start tilt from a button.
8. Online play that feels local
Online matches run on an authoritative Ktor server on Google Cloud Run: a 60 Hz simulation, snapshots to clients 20 times a second, and client-side prediction for your own boat. The symptom that started this work was specific: players joining on phones in Bangalore felt noticeably worse than the host, against a server in Mumbai. None of the fixes below needed more or bigger servers.
Don't send game state over TCP alone
A WebSocket is TCP, and TCP delivers in order: one lost packet holds back everything behind it until it is retransmitted. On a lossy mobile link that is a freeze followed by a jump, and no amount of smoothing can hide it. Snapshots and inputs are absolute and superseded within a frame, so a late one is worthless. When it can open, they travel on an unreliable, unordered WebRTC data channel beside the socket. The handshake, rooms and control messages stay on the socket, which also carries the WebRTC signalling and takes the traffic back whenever the channel cannot open or dies. The interface is callback-based so the iOS side can be implemented natively in Swift.
One slow phone must not freeze everyone
The server wrote each snapshot to every player's socket, one after another, while holding the simulation lock. One congested phone could hold up the next player's update and the next simulation step. Now every seat has its own writer with at most one pending world update: a newer pose replaces an unsent one, and one-shot cosmetic events are merged forward instead of lost.
// The shape of the fix: a latest-only mailbox per seat (simplified).
class SeatOutbox(socket: Connection, scope: CoroutineScope) {
private val latest = Channel<Snapshot>(Channel.CONFLATED) // an unsent pose is replaced
init { scope.launch { for (snapshot in latest) socket.send(snapshot) } }
fun offer(snapshot: Snapshot) { latest.trySend(snapshot) } // never blocks the simulation
}
The test that guards it blocks one seat's writer on purpose and checks that the other seven still receive every update.
Inputs must not pile up
The client launched a new coroutine for every input sample, so behind a congested socket stale controls queued up and arrived late. A single ordered writer now merges queued stick positions into the newest one while keeping every fire press. Its queue is bounded to 64 samples, and an overflow reports a reconnectable interruption instead of growing a backlog.
Don't simulate the same input twice
Reconciliation replayed every unacknowledged input on top of each server snapshot. But the server keeps applying the newest input every tick until another arrives, so during an upload stall it had already simulated part of that movement, and the client simulated it again. The spring that hides corrections turned the error into a visible surge and pullback. A deterministic replay reproduced it where earlier tests had not. The fix estimates the offset between input sequence numbers and server ticks from advancing acknowledgements, and replays only the inputs the server has not simulated yet.
Size the buffer from measured jitter
Remote boats are drawn slightly in the past, from a buffer of snapshots, so a late packet does not cause a stutter. Ours was sized as a multiple of the snapshot interval with an 80 ms floor, and the floor meant that sending snapshots faster barely helped: in our network benchmark, 60 snapshots a second still showed remote boats 142 ms old at the 95th percentile, against 35 ms with a plain two-interval buffer on a clean link. Worse, the "adaptive" mode could not shrink at all at the shipping rate. Now each connection measures its own arrival jitter (how late each snapshot is relative to the tick it shows, over a window) and sizes the buffer as one interval plus a small margin plus the measured queueing delay, capped at 250 ms. It grows immediately and shrinks gradually. We also measured and rejected an asymmetric render clock: it cost 17.7 ms of delay for nothing.
Hide corrections, not movement
When the server disagrees with prediction, the correction eases in with an exact critically damped spring instead of snapping. A position error settles about 95% in 190 ms, at any frame rate, and heading and jump height are eased too. Two subtler bugs mattered as much: reconciliation preserved the physics endpoint instead of the pose actually on screen, and an update that confirmed the prediction exactly still reset the interpolation segment, pausing movement for a moment. Remote boats use velocity-aware cubic interpolation with bounded tangents so impulses do not overshoot, and local collision checks coast rival boats forward by up to 100 ms of predicted time, so you do not bump into where a rival used to be. Respawns are cuts, never a slide across the map.
Put the numbers in the pause menu
The online pause menu shows frame rate, snapshots processed per second, the longest gap between updates and the size of the latest visual correction. They stay on the device, but a report of "it felt laggy" now comes with numbers attached.
9. Platform paper cuts
Each of these shows up on only one platform, which is exactly why they are easy to miss:
- Blank 3D view in the browser. CMP for the web attaches a shadow root to the element you mount
it on. Mount it on
document.bodyand it hides the WebGL canvas next to it. GiveComposeViewporta dedicated full-windowdiv. - A GL view that drifts on macOS. An embedded AWT GL canvas ended up in the wrong place inside Compose layouts. We render into an offscreen CGL context and draw the image with Compose, trading a readback for a view that lays out, clips and scrolls like everything else.
- A HUD over a 3D view. On Android the GL view sits behind the app window, so the Compose HUD and touch controls draw on top of it. In the browser nothing Compose paints shows through the WebGL canvas, so the web build draws its HUD inside WebGL. On iPhone the Compose HUD runs over the Metal view at 15 Hz.
- Shipping a CMP desktop app to the Mac App Store. LWJGL extracts native libraries from its JARs
at runtime, so signing your loose dylibs is not enough: Gatekeeper still warned about
liblwjgl.dylibon a second Mac. Exclude LWJGL's native JARs from the store classpath, ship the signed libraries in the bundle, and have your packaging script prove they load from inside the app. If the sandboxed app accepts incoming connections (ours does, for WebRTC from phones on the local network), App Review will ask why under guideline 2.4.5(i), so explain it in the review notes up front. And uploading withxcodebuild -exportArchivefailed at the account step for us; wrapping the signed app in an.xcarchiveand uploading from Xcode's Organizer worked. - UI under the Dynamic Island. Apps targeting Android 15+ are edge-to-edge, and iOS always is. Pad
your Compose UI with
WindowInsets.safeDrawing. - Test on a device before you trust a number. The simulator and the desktop are fine for logic and screenshots. They are not a performance benchmark.
10. A workflow that kept five platforms honest
- Use the web build as your fast playtest loop. Shared Kotlin means a netcode or gameplay fix lands on every platform, and the browser build deploys in minutes while native apps wait for store review. We tuned most of the multiplayer against the hosted web client, then shipped native builds once it felt right.
- Version the protocol. Redeploying the website cannot update an app already installed on someone's phone. When the wire format changes we bump a protocol number and release the server together with the new native builds.
- Test the hard parts on the JVM. The shared code had 390 desktop tests during the multiplayer work, including deterministic replays of network impairments. The replay that reproduced the surge-and-pullback bug did what live playtests could not: fail the same way every time.
- Write down every measurement. Each fix above has a dated write-up with the device, the build, the method and the numbers, and this article is built from them.
11. Selling premium boats with RevenueCat on every platform
BoatBrawl's premium boats are a one-time lifetime unlock behind a premium_boats entitlement. The
paywall only appears when a player picks a premium boat; launching the app, signing in and playing never show it. We
draw our own purchase sheet in Compose and let RevenueCat handle store billing, receipts and entitlements.
On iOS and Android the purchases-kmp SDK is called straight from shared Kotlin. We identify the
customer with the same Firebase user ID the rest of the game uses, so a purchase follows the player across devices
and our server can check it (simplified):
Purchases.configure(apiKey)
Purchases.sharedInstance.logIn(firebaseUid, onError = { showError(it) }) { customerInfo, _ ->
hasPremium = customerInfo.entitlements.active["premium_boats"]?.isActive == true
}
Two practices saved us. Only debug builds may use RevenueCat's Test Store: a Gradle property generates a build constant, and release binaries always use real store keys. And on macOS, where our Compose Desktop app is a JVM process, we wrapped RevenueCat's Apple SDK in a small Swift library with a C interface and call it through JNA, with StoreKit running on AppKit's main queue. The web version ships the free boats only.
12. We open-sourced the fixes: Joyframe
None of the fixes above is really about boats. Any 3D game with sound can stutter the same way, any Android GL game can hide the same 39 fps, any follow camera jitters over a fixed-step simulation, and any couch game meets the same split-screen and controller problems. So we pulled them out of BoatBrawl into Joyframe, an Apache-2.0 KMP library for 3D games inside CMP apps, on Android, iOS (Metal), desktop and the browser (Kotlin/Wasm):
// commonMain
implementation("io.github.rehaancubess:joyframe:0.1.0-alpha04")
- Smooth by default:
GameViewships the Android render thread, render scale, 60 Hz hold and sustained mode, plus the phone resolution caps, asGameViewOptionsyou can override.FramePacinggives you the numbers. - Game feel:
ChaseCamera,FixedTimestep, and for water scenesBuoyancy,WhirlpoolandWeather. - Couch play:
SplitGameView, every controller withGamepadSeats, two players on one keyboard, andDeviceTilt. - Audio: the non-blocking
AudioPlayer,MusicPlayerwith fades, and stereo pan from a position withcamera.hear(...).
It is an alpha: models are static (no skeletal animation), and there is no physics engine. If you need those, or
a visual editor, Kool, KorGE, libGDX or a full engine may suit you better; the repository has
an honest comparison.
Among the Kotlin options, what Joyframe adds is a 3D scene inside an ordinary CMP app on all four
platforms from one KMP commonMain.
It also suits AI-assisted development. Scenes are plain Kotlin data with no editor files; the obvious
code is already the fast code; OffscreenRenderer renders a frame to a PNG so an assistant can look at its
own work; and an AGENTS.md in the repository tells coding assistants the conventions and pitfalls.
Checklist for your own KMP + CMP game
- Frame intervals, late %, p99 and worst, measured in the game loop, in release builds.
- No native audio call on the game thread; stale sounds dropped; no per-frame volume or rate updates.
- Android: GL thread decoupled from composition; render scale below 1; sustained performance; 60 Hz on 120 Hz panels.
- iPhone and browser: 3D drawable capped, HUD at native resolution.
- Fixed-step simulation, interpolated rendering, damped camera that cuts on teleports.
- Split screen in one surface on every platform: scissored viewports on OpenGL and WebGL, one pass with a shadow map per pane on Metal.
- Controllers by button position, stick Y up, seats that survive disconnects.
- Tilt from gravity, zeroed where the phone is held, started from a tap.
- Compose on a dedicated
divon the web; safe-area insets on phones. - Game state over an unordered channel; control messages on the socket.
- One latest-only outbox per client on the server; inputs merged, fire presses kept.
- Replay only inputs the server has not simulated; buffer sized from measured jitter; corrections eased, respawns cut.
- The web build as your playtest loop; a protocol version for installed apps.