Native controller apps for the Paper Balloon party-game suite. The TV/computer is the display; phones are the controllers: scan the room code the display shows and your phone becomes the gamepad.
The launcher (home screen) is fully native per platform. Each game's controller is a remote web page loaded full-screen in a hardened top-level web view with no launcher chrome over it — games ship controller changes without an app update.
CONTRACT.md is the launcher⇄game contract: the player name
and its rename sheet, leaving, the status-bar color scheme, the safe area, and the
_paperballoon._tcp room advertisement that puts a one-tap join card on home with no
scan at all: a native display app (tvOS / Android TV) announces its room code,
and a controller already in a room relays it so browser-hosted rooms are findable
too. Both apps implement it identically.
android/— Jetpack Compose / Material 3 app (Kotlin)ios/— SwiftUI app (Swift, project generated with XcodeGen; see ios/README.md)third_party/zxing-cpp/— git submodule tracking upstream zxing-cpp; Android compiles its QR decoder from here rather than using the published AAR (why:android/app/build.gradle.kts)
Standard Gradle project — open android/ in Android Studio, or:
git submodule update --init third_party/zxing-cpp # once per checkout (zxing-cpp is built from source)
cd android
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties # once per checkout
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apkQR scanning is an in-app CameraX + zxing-cpp scanner — fully on-device, no telemetry (CAMERA permission, with manual room-code entry as the fallback).
cd ios
xcodegen generate # brew install xcodegen, once
open PaperBalloon.xcodeprojQR scanning uses AVFoundation (camera permission) and degrades to manual room-code entry in the simulator.
Both apps ship the 11 HexStacker locales (en + de, es, fr, it, ja, ko, pt, ru,
tr, zh): Android in res/values[-XX]/strings.xml, iOS in
PaperBalloon/Resources/*.xcstrings. Per-game display copy (e.g. player counts)
lives in those same string resources under game_<id>_* keys, resolved by game
id at load time — games-manifest.json itself is purely structural and holds no
translated text.
python3 tools/check_l10n_sync.pyverifies the three stay in sync: locale coverage on both platforms, identical
translations for every text both apps share (matched via the English source),
and byte-identical manifest copies. Platform-only strings are declared in the
script's ANDROID_ONLY / IOS_ONLY lists — an undeclared one-sided string
fails the check. Run it after any string change.
-
android/app/src/main/assets/games-manifest.json— the bundled games list; drives the home screen, scan/typed-code resolution, and relay probing. Cover art (art, 16:9) and the square brand mark (icon, used by the nearby-room card) both sit next to it inassets/artwork/. The iOS app bundles copies underios/PaperBalloon/Resources/— keep them in sync when the manifest changes. The bundled copy is only the first-run seed: once per launch both apps fetchpaperballoon.games/games-manifest.json(served no-cache from the site repo), persist it, and render from it — so a new game or status flip is one site deploy, no app update. Art the current build didn't ship is downloaded into a URL-keyed cache that is never revalidated, and shipped art is matched by file name — so a changed image must get a new file name (e.g.hexstacker-16x9-v2.webp) in the served manifest and in both bundles; changed bytes under an unchanged name go unnoticed by design. Not a?v=bump: the query string lands inside the file name both bundle lookups use, so every app would re-download art it already ships. Trailers (video) are never bundled, so a changed trailer is just a?v=bump, once the new file is live. Encode covers withtools/encode_artwork.sh <master>... # 1280x720 q85 -> both bundles
which is the ceiling both apps decode to, so a larger source is download weight no screen ever shows. Not a build step, and it takes masters — every run is another generation of lossy re-encode.
-
android/app/src/main/java/games/paperballoon/controller/—data/(manifest model, join resolution, relay probe, LAN room discovery, prefs),ui/main/(home),ui/game/(WebView game host),theme/. -
ios/PaperBalloon/—Data/(same responsibilities as Androiddata/),UI/Main/,GameHost/(WKWebView game host),Theme/. -
The launcher icon (Android
ic_launcher_*layers and legacy mipmaps, iOSAppIcon.iconplus theAppIcon.appiconsetfallback) is generated from one drawing: edit and runuv run tools/app_icon.py, then commit what it writes.