Two 3D games in two hours, and our boat game on Fire TV: life after Shipaton with Kotlin Multiplatform
RevenueCat's Shipaton is over. We entered BoatBrawl, an arcade boat‑combat game written in Kotlin Multiplatform (KMP) and Compose Multiplatform (CMP) that runs on iPhone, Android, Mac, Windows and the web. While we were building it we pulled the hard parts out into an open‑source library, Joyframe, and wrote up every fix that went into it. A hackathon deadline is a good time to stop. We kept going instead, and two things happened in the week after: Ashwin, who built BoatBrawl with me, made two brand‑new 3D games on Joyframe: Island Panic and SkyFall. Each one took about an hour with Claude Code, and each was a complete game at the end of that hour. BoatBrawl now runs on Amazon Fire TV Stick. This post covers both: what a game on Joyframe looks like when you are not the people who wrote the boat game, and what it took to fit a 3D Kotlin game onto a streaming stick.
Links: Joyframe on GitHub · Maven Central · Play BoatBrawl · The first article: one Kotlin codebase, five platforms, 60 fps
The real test of a library: someone else's game
Joyframe came out of one game. Every default in it was tuned on boats, water and a chase camera. Whether it is a library or just our engine with the serial numbers filed off only shows when it is used to build something that isn't a boat game. So that was the experiment. Ashwin added one Gradle line and gave Claude Code a game idea:
// commonMain
implementation("io.github.rehaancubess:joyframe:0.1.0-alpha04")
An hour later there was a finished game. Then Ashwin did it again, in a completely different genre.
Game 1: Island Panic
Island Panic is a couch party brawler for up to six players. Everyone stands on an 8×8 island of grass tiles. You can run and you can shove. After ten safe seconds the tiles start to crack, glow orange, then red, and drop into the sea, faster and faster. A shove near the edge is stronger and is angled out to sea. Fall in and you're out. Last one standing wins the round. A session is five rounds. You score for everyone you outlast (+50 each), for every rival you knock into the water (+75), and for winning (+150). The top three finish on a podium.
What's under it
- Kotlin 2.3, KMP for desktop JVM and Android, Compose Multiplatform for the interface, Joyframe for 3D, and Ktor for the phone controllers.
- No game engine and not a single art or audio file. The only bundled asset is one open‑licensed font.
- Models are generated in code: low‑poly spheres, palm fronds, crack lines and the shoreline water are all built by small functions.
- Each character is made of rigid parts (body, head with cap, eyes, two big gloves, two feet) and animated in code. They bob when they run, throw alternating punches when they shove, flail when they're knocked back, and blink at random.
- Sound is generated by maths. At startup the game synthesises every effect as 44.1 kHz PCM:
- A shove is white noise through a sweeping filter (a whoosh).
- A hit is a sine wave dropping from 220 Hz to 60 Hz.
- A splash is decaying noise with a “bloop” underneath.
- The music is a 16‑second loop at 120 bpm with a triangle‑wave bass, chord stabs, a marimba‑like pentatonic melody, a shaker, a kick and a clap.
- Joyframe's audio player takes raw 16‑bit PCM, so the synthesiser's output goes straight in.
- The phone controllers are hosted by the game itself. A small Ktor web server runs on the local Wi‑Fi, with no relay and no account. Messages are tiny JSON objects, and every phone sends a running count of button presses, so a quick tap that starts and ends between two packets still counts.
{ "t" : "in" , "x" : 0 , "y" : 100 , "p" : 1 , "pc" : 14 }
// stick up, push held, 14th press
It even has its own QR encoder. The open‑source QR library it started with builds a desktop image class that doesn't exist on Android, so Claude Code wrote a 280‑line encoder (byte mode, error‑correction level M, versions 1-10, Reed-Solomon over GF(256), all eight masks). A test checks it module by module against the library.
What Joyframe did for it
The 3D view is just another Compose element. The game screen is a Box with a GameView filling it and the HUD drawn on top in plain Compose:
Box(Modifier.fillMaxSize()) {
GameView(
presenter.frame(assets, camera3d.gpu(match, shake), match),
Modifier.fillMaxSize()
)
MatchHud(match, session, tick, couch) // plain Compose on top
}
Under that one call, each platform does something different, and every choice came from a problem we had already hit on BoatBrawl.
- Mac - renders off‑screen and composites into Compose, so layout and overlays just work.
- Android - draws on its own render thread, with the 0.8 render scale, resolution cap, 60 Hz hold and sustained‑performance mode we measured on real phones. Island Panic got all of that by default.
Other important details:
- Scenes are plain data. Every frame the game builds a list of instances (model, transform, material overrides, opacity, hit flash) and hands it over. There's no scene graph to keep in sync, so the simulation stays in charge and the renderer can never change the game. Joyframe validates every frame when it is created, so one unit test builds every frame of a full bot match on the JVM and fails on a mistyped material name. You get a failing Gradle test instead of a blank screen.
- No shader code at all. Lighting, shadows, fog, additive glow and the stylised water shader are built in. The calm turquoise shoreline that deepens out to sea is just a water mesh whose depth and wave energy rise with distance from the island. One model, six players. Per‑instance material overrides recolour one character model for all six teams, per‑instance opacity fades falling players, and the built‑in hit flash turns them white when they're punched.
- The AI can look at its own work. Joyframe's OffscreenRenderer writes frames to PNGs without opening a window. Ashwin's Claude Code session used it at every step: a tool runs a scripted match, renders stills and composites the Compose HUD on top. Those stills caught bugs that tests couldn't, like podium blocks sinking into the ground. Every Island Panic screenshot in this post came from that tool.
1,039 draw calls → 65
The first version drew every grass tuft, flower, palm frond and boulder as its own object. A laptop doesn't care. A TV stick does, because each draw call costs driver time and shadows double the count. The fix is a classic low‑poly trick. Every colour the island uses goes into a 16×16 palette texture, and each tile is merged with its cliff, rocks, plants and palms into one mesh. Every triangle points its texture coordinates at a single texel:
// Each triangle samples one palette texel, so a whole tile is one mesh and one material.
val uv = Palette.uv(color) // centre of that colour's texel
positions += outer.point(inner.point(vertex))
normals += outer.normal(inner.normal(normal)).normalized()
uvs += uv
The colour never bleeds, even with filtering and mipmaps: all three vertices share one UV, so the screen‑space derivatives are zero and the GPU always samples that exact texel at full resolution. A test fails if a busy frame ever goes over 300 draw calls again.
Game 2: SkyFall
The second game has nothing in common with the first except the library underneath. SkyFall is a skydiving race down a desert canyon. Four divers drop nearly seven kilometres through floating rocks and hot‑air balloons. You fly through rings, skim obstacles for CLOSE! near‑miss points, and chain them into a combo multiplier that climbs as high as ×10. Then you bank the combo before a crash turns it into a WIPEOUT and a COMBO BREAK. Each diver has an ability (a magnet, a wind blast or a shield), and the last ten seconds are THE DROP, where everything scores double. You pick a diver class (Sky Glider, Heavy Hawk or Wingsuit). In the clip, the keyboard player takes on three bots: Zephyr, Cirrus and Gusto. Island Panic is a one‑screen party game. SkyFall is a fast, forward‑moving, particle‑heavy race. The same GameView, the same instance lists and the same lighting covered both, and both were finished in about an hour.
Why an hour is possible now
- Everything is plain Kotlin. There are no editor files, scene files or binary assets the assistant can't read or write. A scene is a list of data classes.
- The obvious code is already the fast code.
- The defaults are the ones we measured on real phones, so an assistant can't accidentally build the 39 fps version of an Android game.
- It can check its own output. The off‑screen renderer turns “does it look right?” into a PNG the assistant can open, and
AGENTS.mdin the repository tells coding assistants the conventions and pitfalls up front. - The hour covers the game: rules, bots, scoring, UI, art, audio, controllers. Joyframe covers the part that took us weeks on BoatBrawl: making a 3D scene smooth inside a Compose app on every platform.
BoatBrawl on Fire TV
Meanwhile, BoatBrawl itself got a new platform: Amazon Fire TV Stick. Plug it into the TV, connect a couple of Bluetooth controllers, or scan the QR code and use everyone's phones, and you have couch boat combat on the big screen. It's in device testing right now. It plays well and we're tuning it further every day. Here's what it took.
A stick is not a phone
A Fire TV Stick has far less GPU and CPU headroom than the phones we tuned for, and it drives a 1080p or 4K TV. Rather than compromise the game everywhere, BoatBrawl detects the stick and switches to a dedicated low‑power profile. Phones, tablets and other Android TVs are untouched.
// Fire OS reports Amazon "AFT" models.
val fireTv = Build.MANUFACTURER.equals("Amazon", ignoreCase = true) &&
Build.MODEL.startsWith("AFT")
On a stick, the profile:
- Paces rendering to a steady 30 fps instead of chasing 60. An even 33 ms looks better than 60 that keeps dropping frames.
- Crucially, the simulation still runs at a fixed 60 Hz, two steps per frame, so boats handle exactly as they do on a phone. A test drives a normal engine and the Fire TV engine with the same inputs at 30 updates a second and requires identical positions, velocities and headings.
- Renders the 3D scene at no more than 960×540 and lets the TV's scaler do the rest. The Compose menus and HUD stay sharp because they're drawn at full resolution.
- Turns off shadows and uses the low‑quality water shader.
- Keeps every gameplay object and drops the decoration. Wake trails, water ripples, spray, snow, sand and knockout blasts go. Every boat, projectile and pickup stays, and a test checks that.
- Moves the simulation onto a worker thread. Controller state is snapshotted on the UI thread first, so the worker never reads Compose state or live controller objects. The GL thread just draws the newest result.
- Redraws the HUD 10 times a second instead of every frame.
- Thins out the bots' thinking. Offline matches cap at two bots, and each bot re‑plans its steering 10 times a second on a staggered schedule (
tick % 6 == id % 6), so they never all think on the same frame. Their physics and collisions still run at 60 Hz. - Stops allocating per frame. Interpolation vectors are reused and scene instances go into one scratch list, because garbage‑collection pauses on a small heap show up as hitches.
// GL thread on Fire TV: pace to 30 fps, then draw the newest simulation state.
override fun onDrawFrame(unused: GL10?) {
val remaining = 33_333_333L - (System.nanoTime() - lastDrawStart)
if (remaining > 0L) LockSupport.parkNanos(remaining)
lastDrawStart = System.nanoTime()
draw(latestSnapshot())
}
And because the first rule from our last article still holds (measure release builds), there's a dedicated fireTvBenchmark build type. It is the release configuration, non‑debuggable and debug‑signed, so it can be sideloaded onto a stick and measured.
The remote is a remote, not a controller
The input bug was the more interesting one. On Android we decided whether a key event came from a gamepad like this:
source and (InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_JOYSTICK) != 0
It looks right, but it isn't. An input source is a class bit plus a device bit, and SOURCE_GAMEPAD (0x401) shares its class bit (SOURCE_CLASS_BUTTON, 0x1) with the keyboard and the D‑pad. On a phone nobody notices. On Fire TV, the remote counted as a gamepad: it could take a seat in the lobby or drive a boat. On the stick we now require the full mask:
source and SOURCE_GAMEPAD == SOURCE_GAMEPAD ||
source and SOURCE_JOYSTICK == SOURCE_JOYSTICK
Then we gave the remote one clear job:
- In menus, it navigates. The D‑pad moves Compose focus and Select activates.
- Our custom arcade buttons drew over Compose's focus indication, so on Fire TV we draw a focus ring after the button content, where nothing can cover it.
- In a match, it pauses. BACK, MENU and PLAY/PAUSE open the pause menu, and everything else on the remote is swallowed so it can't steer, fire or press a HUD button.
- One subtle fix: the pause menu opens on key down, so the matching key up is swallowed too. Otherwise that release would land in the freshly opened menu as a second BACK and close it again.
Bluetooth controllers are untouched. They keep their usual by‑position mapping, seats and rumble. Touch controls and ads are off on Fire TV, and the 3D boat preview in the menus renders smaller and at 10 fps, because a menu doesn't need 60.
What's next for Joyframe
Shipaton is over, but Joyframe isn't a hackathon project. The two new games were the most useful feedback it has had so far, because
Comments
No comments yet. Start the discussion.