Bodogen is a speaking turn timer and resource tracker for board game nights. One person's US$5.99 purchase removes ads for everyone at their table.

Rule sets are passed around the table as QR codes. When the host who made one has paid, every guest playing with it is ad-free, including someone who installed the app two minutes ago. No account, no server, no network call. Bodogen ships on the App Store and Google Play from one Kotlin Multiplatform codebase, about 91% shared, UI included.

We are entering the HAMM Award, Ship Kotlin Everywhere (JetBrains), the RevenueCat Design Award and the Grand Prize.

The problem

Someone has been thinking for a long time, and nobody wants to be the one who says "are you done yet?" Meanwhile half the table has forgotten whose turn it is.

Kitchen timers don't fix this. They tell you time is passing, not whose time it is or how much each player has left. So one person ends up keeping time instead of playing.

What it does

Bodogen puts a turn timer and a resource tracker in one app.

A timer you don't have to look at. It announces the turn out loud ("It's Alice's turn!"), warns at 3 minutes, 1 minute and 30 seconds, and counts the last 5 seconds. As time runs down the screen warms toward red, so the pressure shows in peripheral vision.

Turn order readable across the table. The current player's name fills the screen. The next player and everyone's remaining time are visible at a glance.

One tap to pass. Press the large button when your turn ends, like a chess clock. At zero the clock keeps counting into negative numbers, so house rules like "overtime is a scoring penalty" need no bookkeeping.

Saved players. Register your group once. After that, pick who's playing, set the time, start.

Resource tracking on the same screen. Switch tabs and the timer becomes a tracker. Define resources with your own names and colors. Large +/- buttons work one-handed, tap a number to type it, set caps, swipe a row to zero it at the end of a round. Every change is logged.

Rule sets you keep, and share by QR code. Save a setup under a name and reload it in one tap. Share it as a QR code that carries the settings themselves: no server, no room ID, no account.

Offline by design

Game data never leaves the device. No accounts, no sign-in, no server of ours. Players, rule sets and history are stored locally, and rule sets move phone to phone as QR codes. The timer and tracker work in a basement venue with no signal.

The only network traffic is the ad SDK and purchase validation. Buy the ad-free unlock and the app goes quiet.

Who it's for

Game nights, board game cafés, gaming retreats. Heavy euro games, worker placement, area majority. Tournaments and timed rules explanations. TRPGs and social deduction. And as a plain game clock for shogi, go and chess.

How it makes money

Bodogen is free with ads. One US$5.99 purchase removes them, for the buyer and for everyone playing with a rule set the buyer created.

Rule sets travel as QR codes with the entitlement inside. Scan a paid host's rule set and your ads are gone, even if you installed the app two minutes ago and have never paid. One person buys for the group, the way one person owns the board game.

$5.99 is above the impulse point for a utility app on purpose. It isn't a per-person upgrade, it's the table's copy: an ad-free game night for four to six people, indefinitely, for less than an expansion.

Three reasons for the model:

  1. Board game groups already have an owner. Someone buys the games, sets up the table and explains the rules. That person is the most likely buyer and the most generous person in the room.
  2. Guests get the ad-free product on day one instead of meeting a paywall.
  3. The purchase moment arrives on its own. Ads come back when a guest hosts their own night, and by then they know what $5.99 buys.

We think a purchase that benefits four other people converts better than one that benefits only the buyer. That is a hypothesis. The launch will tell us.

Purchases run through the RevenueCat SDK on both platforms. Ads are AdMob.

How we built it

Bodogen is Kotlin Multiplatform, including the UI: Compose Multiplatform throughout, no SwiftUI screens. ContentView.swift is 18 lines that host a Compose view controller. MainActivity.kt is 16 lines calling the same App().

Measured across the repository, excluding build output:

  • commonMain (screens, navigation, ViewModels, domain, data): 200 files, 12,339 lines
  • commonTest: 48 files, 3,573 lines
  • androidMain: 21 files, 541 lines
  • iosMain: 21 files, 516 lines
  • Swift, 3 files: 119 lines
  • androidApp Kotlin, 2 files: 26 lines

Platform code totals 1,202 lines, about 9%. Every screen, every ViewModel, the domain layer, the database and the whole test suite are shared.

Kotlin 2.4.10, Compose Multiplatform 1.12.0, Room KMP 2.8.4, Ktor 3.5.2, Koin 4.2.2, navigation-compose, lifecycle-viewmodel-compose. RevenueCat for purchases, AdMob for ads. The only substantial Swift is a 68-line AdMob bridge.

Our rule: keep every expect/actual boundary thin and as low in the stack as possible. One method touching an OS API, not a feature.

Challenges we ran into

The same bug, two causes

Speech is an expect class with one method, announce(text). Android uses TextToSpeech, iOS AVSpeechSynthesizer.

Passing turns quickly made announcements pile up on both platforms, for different reasons. On Android, QUEUE_FLUSH drops queued utterances but not the one already speaking, so stop() has to come first. On iOS, AVSpeechSynthesizer queues by default and needed stopSpeaking(at: .immediate). Android also needs an isReady flag because initialization arrives on a callback.

The language logic stayed shared. VoiceLocale.resolveVoiceLocaleTag() in commonMain picks the tag, falling back to Japanese outside the five supported languages, and each actual receives a string.

A Composable as the expect declaration

For scanning we made the Composable the boundary: expect @Composable fun QrCodeScannerView(onCodeScanned, modifier). Android wraps ZXing's CompoundBarcodeView in an AndroidView, iOS wraps AVCaptureSession and AVCaptureVideoPreviewLayer in a UIKitView.

Permissions diverged more than the camera code. Android requests the camera through ActivityResultContracts.RequestPermission and renders its own denial UI. On iOS the system dialog does it, driven by NSCameraUsageDescription. Same signature, different responsibility, so we wrote the contract into the doc comment: each actual owns its permission UI.

A Compose Multiplatform trap cost us time here. Since 1.7, relying on UIKitView's resize notification alone leaves previewLayer.frame at (0,0,0,0) and the preview stays black. Overriding layoutSubviews() and following the bounds inside CATransaction.setDisableActions(true) fixed it.

The one we didn't split

The alert tones aren't audio files. AlertSoundSynthesizer in commonMain generates the PCM waveform in Kotlin: 44.1 kHz, mono, 16-bit, from sin and exp. Both platforms produce the same sound and the app ships no audio assets.

The actuals only play it. Android hands the buffer to AudioTrack with USAGE_ALARM, so it follows alarm volume and sounds in silent mode. iOS adds a WAV header for AVAudioPlayer under AVAudioSessionCategoryPlayback, and has to retain the player or it gets deallocated mid-playback.

An entitlement inside a QR code

The hard part wasn't generating the code. It was deciding what a QR code can mean with no server to ask.

A Bodogen QR carries the rule set (resource names, colors, starting values, caps) and the flag saying its creator paid. It has to scan across a table, in a dim café, off someone's phone screen at an angle. Every field costs scan reliability, so the payload format is tight.

We considered signing it and decided not to. Any key would ship inside the app, so a signature raises the effort to forge a rule set without making it impossible. Real verification needs a server, and a server costs the offline operation the QR exists for.

So the flag is a convenience between people at the same table, not a lock. We spent the payload budget on scan reliability instead of a signature that only looks like security. If forgery becomes a real problem, it will be because the app found an audience, and that is when the trade is worth revisiting.

What we learned

The feature we didn't plan

Bodogen started as a timer. The resource tracker exists because one friend, who hosts most of our game nights, kept asking for it. Once it was built the reason was obvious: the person running the clock is the person tracking resources, so both belong on one screen. We got there by watching someone host, not by planning.

The business model came from the same place

The shared entitlement wasn't in the original plan. It appeared while we worked out what to charge for. Removing your own ads is thin. Removing your friends' ads isn't, and the person most likely to pay is the host, who is already covering things all evening.

Two people, one of them writing code

One of us decided what to build, the other implemented it with coding agents. We had assumed two developers meant two people writing code in parallel. The bottleneck turned out to be deciding, not typing, and a second implementer would mostly have added coordination. Most of the app worked within three days.

What's next

A portal for user-made rule sets. Today a rule set only travels between people in the same room. Publishing and browsing setups for specific games would let a new player arrive already configured. That means running a server, and the signing question comes back with it.

Rule sets from the rulebook. Setting up resources by hand is the last friction before a game starts. We want to read the rulebook and generate the setup.

Tie-ups with publishers. Official setups for real titles.

A business. Bodogen is a hobby today. The plan is a side business first, then something that stands on its own.

Categories we're entering

HAMM Award. US$5.99, one time, removes ads for everyone playing with a rule set the buyer created. The entitlement rides in the QR code, with no account and no server to check with. Board game groups already have an owner, and this charges that person while putting the ad-free product in a guest's hands on day one. The growth loop and the paywall are the same mechanism: guests meet the good version first, and hit the ads only when they decide to host. Details in "How it makes money".

Ship Kotlin Everywhere — JetBrains. Both stores, one Kotlin Multiplatform codebase, Compose Multiplatform for the UI, no platform-specific screens. About 91% shared: every screen, every ViewModel, the domain layer, the Room KMP database and the whole test suite live in commonMain. The remaining 1,202 lines are thin expect/actual wrappers. The Challenges section covers three of those boundaries, including one where the right boundary turned out to be none. Both store links are in this submission.

RevenueCat Design Award. The screen sits on a table, not in a hand, and the design follows from that. The current player's name is readable from the far side of a board. The next player is visible without a tap. The +/- targets work one-handed while the other hand holds cards. A swipe zeroes a row instead of thirty taps. At zero the clock keeps counting instead of stopping and demanding attention.

The part to look at is what happens as a turn runs out. The whole screen warms toward red, and past zero the interface goes unsteady. Nobody reads a number to feel it; the table catches it while looking at the board. Screenshots 1 and 2 are the same screen before and after. Same purpose as the spoken countdown, in color instead of sound.

The alert tones are synthesized in shared Kotlin, so both platforms sound identical down to the waveform.

Grand Prize. TODO — post-launch growth. Downloads, revenue, paying customers, and how the shared-entitlement loop behaved: how many guests scanned a paid host's rule set, and how many of them later bought.

Built With

  • admob
  • android
  • compose-multiplatform
  • ios
  • koin
  • kotlin
  • kotlin-multiplatform
  • ktor
  • revenuecat
  • room
Share this project:

Updates

Submission history