Orbiteer
Orbiteer
Last updated: 7/7-2026
A planet-merging puzzle game for Android, built with Kotlin and Jetpack
Compose. Place tiles on a 6×5 grid, merge matching planets into rarer
ones, and chase deeper ranks, badges, and leaderboard position across
post-launch content built on top of the core loop. Questions about
anything below? Reach out at
immerse.pda@gmail.com.
▶
Get Orbiteer on Google Play
Core Gameplay
- 6×5 grid with flood-fill merge detection — any connected group of 3+ matching tiles merges, in any shape, not just rows or lines
- Tier-skip scaling — bigger groups jump further: a group of N tiles skips N − 2 tiers, rewarding setting up large clusters over simple 3-tile matches
- Combo multiplier — chaining merges quickly builds a live combo, up to ×8, applied directly to score as you play
- 13-tier merge chain per theme, from a common starting tile up to a unique final "legendary" tile
Progression Systems
- Ranks — 50 ranks (10 titles × 5 sub-ranks, Apprentice through Immortal), with a threshold curve tuned from real playtest data so no single game can skip a large chunk of the ladder
- Prestige — up to 500 prestige levels, unlocked at max rank; resets XP but keeps everything else, with a permanent score bonus per level
- Badges — 40 badges across Gameplay, Score, and Playtime categories, each granting a permanent score bonus
- Score multiplier — badge and prestige bonuses combine into a single multiplier (capped at ×4.50 fully maxed), locked in at the start of each game and applied live to the running score, not just revealed at game-over. A badge earned mid-game affects the next game, not the one that earned it.
Themes
11 unlockable visual themes (Deep Space, Ocean Depths, Cozy Farm, Neon
City, Solar Forge, Ancient Ruins, Cherry Blossom, Candy Land,
Prehistoric, Lunar Dream, The Void), each with:
- Its own 13-tile emoji set and tile names
- A distinct color palette applied throughout the UI
- An animated background style, unlocked by rank index (evenly spread across the full rank ladder)
Themes unlock via XP milestones (aligned to specific rank completions)
or high-score thresholds.
Leaderboards & Social
- Firebase Authentication — every player gets a silent anonymous identity on first launch; optional Google Sign-In links to that identity to preserve progress across devices/reinstalls
- Friend codes — 6-character codes for adding friends without any request/accept flow
- Global (top 100) and Friends leaderboards, backed by Cloud Firestore
- Server-enforced integrity — Firestore Security Rules validate score ceilings and document ownership; username uniqueness is enforced via atomic transactions against a dedicated registry collection
Monetization
- AdMob interstitial ads every 2nd game-over
- Rewarded ads offering a bonus starting tile on the next game
Tech Stack
- LanguageKotlin 2.2.0
- UIJetpack Compose
- Local storageRoom 2.7.1 (kapt)
- BackendFirebase (Auth, Firestore) — BoM 34.15.0
- AdsAdMob
- BuildAGP 8.7.3, Gradle 8.9, Java 17
- CI/CDGitHub Actions
Project Structure
app/src/main/java/dk/pda/orbiteer/
├── game/ # Core game logic (engine-only, no UI/player-progression knowledge)
│ ├── GameEngine.kt # Grid state, flood-fill merge resolution, combo tracking
│ ├── Planet.kt # Tile tier definitions, score/XP values, colors
│ ├── RankSystem.kt # Rank curve, prestige bonus calculation
│ └── BadgeSystem.kt # Badge definitions and unlock conditions
├── ui/ # Jetpack Compose screens and view models
│ ├── GameScreen.kt # Main game UI — grid, popups, results, settings
│ ├── GameViewModel.kt # App state, player stats, game lifecycle
│ ├── ThemeManager.kt # Theme/background definitions and unlock logic
│ ├── AppIcons.kt # Custom vector icon renderer (ranks & badges)
│ ├── OnboardingScreen.kt
│ └── BackgroundRenderer.kt / SoundManager.kt
├── data/ # Room database (PlayerStats, ScoreEntry)
├── auth/ # Firebase Authentication wrapper
├── leaderboard/ # Firestore leaderboard read/write, friend codes
└── ads/ # AdMob integration
Building
Requirements: JDK 17, Android SDK, a Firebase project with
Authentication (Anonymous + Google) and Firestore enabled.
- Add
app/google-services.json from your Firebase project
- Register your debug and release SHA-1/SHA-256 fingerprints in Firebase Console (required for Google Sign-In)
- Set the Web Client ID in
AuthManager.kt
- Publish the Firestore security rules (see
firestore.rules)
- For release builds via CI, set
KEYSTORE_BASE64, KEYSTORE_PASSWORD, KEY_ALIAS, and KEY_PASSWORD as GitHub Actions secrets
Debug builds run directly from Android Studio; release builds
(assembleRelease / bundleRelease) run via the
GitHub Actions workflow on push to master.
The Watchtower
The Watchtower
Last updated: 7/10-2026
A personal uptime monitor for Android, built natively in Kotlin with
Jetpack Compose in the same cyan-and-steel holographic theme as this
site. Add targets — websites, LAN devices, TCP services, JSON APIs —
and The Watchtower checks them in the background, charts their latency
and uptime, and alerts you when something goes down or comes back up.
Questions about anything below? Reach out at
immerse.pda@gmail.com.
Tech Stack
Native Android, 100% Kotlin. Plain HttpURLConnection and
Socket handle the network checks — no third-party
libraries at all, which keeps the APK at roughly 10 MB.
- LanguageKotlin 2.0 (100% Kotlin)
- UIJetpack Compose (Material 3)
- StorageRoom (KSP codegen)
- SchedulingWorkManager
- NetworkHttpURLConnection / Socket — zero dependencies
- SDKMin 26 (Android 8) · Target 35
- Footprint~10 MB APK
Architecture
Single-activity app, four Compose screens behind Navigation-Compose,
one shared AndroidViewModel, and a Repository
that owns all business logic. Layers:
ui/ # screens, theme, reusable holo components, charts
└── MainViewModel — StateFlows the screens collect
data/ # Room entities + DAOs, Repository, SharedPreferences wrapper
net/ # the four checkers + network-transport detection
work/ # MonitorWorker (periodic) + Notifier (channels & alert posting)
Data Model
Two Room tables:
targets — name, type (HTTP | PING | TCP | JSON_API, stored as strings via Room's built-in enum converter), address, port, isLocal, statPaths (the JSON extraction spec), notifyEnabled (per-target mute), and lastAlertAt (alert-state bookkeeping, see below)
results — one row per check: timestamp, success, skipped, latencyMs, statusCode, message. Indexed on targetId and timestamp, pruned to a 30-day window after every batch run
DAOs expose Kotlin Flows, so the UI is fully reactive:
inserting a result from anywhere (manual refresh, background worker)
automatically recomposes the dashboard, sparklines, and detail charts.
Schema changes ship as proper Room migrations (v1→v2 added the two
notification columns with ALTER TABLE).
The Check Engine
Checkers.run(target) dispatches on type, all on
Dispatchers.IO:
- HTTP / JSON API —
HttpURLConnection, GET, 8 s connect/read timeouts, follows redirects. Success = status 200–399. Latency is measured with SystemClock.elapsedRealtime() (monotonic, immune to clock changes). URLs without a scheme get https:// prepended; cleartext HTTP is explicitly allowed in the manifest because most LAN devices have no TLS.
- JSON API extraction — the response body is parsed with
org.json. The user's spec is one line per stat: Label = dot.path, where numeric segments index arrays (disks.0.free). The resolver walks the JSON tree segment by segment; missing paths render as "—" without failing the check. Extracted values are stored in the result's message field, so the detail screen can show the latest stats with zero extra schema.
- Ping — spawns the system
ping -c 1 -W 3 binary and parses time= from stdout, because Java's InetAddress.isReachable() needs raw-socket privileges for real ICMP on Android and silently falls back to TCP port 7. The binary is present on effectively every Android build; if exec fails, the code falls back to isReachable() anyway. A 6-second waitFor guard kills hung processes.
- TCP —
Socket().connect(InetSocketAddress, 5000), measuring time-to-connect. Success = the handshake completed; the socket is closed immediately.
Every checker returns a uniform
CheckOutcome(success, latencyMs, statusCode, message) —
errors are caught and truncated into message, so a failure
is data, never an exception that escapes.
Local-Device Gating
Before checking a target marked local, the repository asks
ConnectivityManager whether the active network has the
TRANSPORT_WIFI or TRANSPORT_ETHERNET
capability. If not, no network call is made at all — a result row with
skipped = true is written instead. Skipped rows are
excluded from uptime math, sparklines, and the alert state machine,
which is what prevents false "down" alerts on mobile data. The
dashboard polls this every 5 seconds to show HOME NETWORK vs REMOTE in
the header.
Background Monitoring
A CoroutineWorker is enqueued as unique periodic work
(ExistingPeriodicWorkPolicy.UPDATE, so interval changes
replace the schedule in place) with a
NetworkType.CONNECTED constraint. Interval is
user-selectable 15/30/60 min — 15 is WorkManager's hard floor. Each run
checks every enabled target sequentially, then prunes old results.
Failures return Result.retry() so WorkManager backs off
and retries rather than dropping a cycle.
The Alert State Machine
This is the interesting part. Alert decisions are made per check,
statelessly derived from history rather than from an in-memory flag,
so app restarts, process death, and mixed manual/background checks
can't lose track:
- Before running a check, fetch the last
threshold + 1 non-skipped results. Count leading consecutive failures → wasDown = failures ≥ threshold.
- Run the check, insert the result.
- Failure: if
failures + 1 ≥ threshold and it wasn't down before → post the down alert and stamp lastAlertAt. If it was already down and hourly reminders are on and ≥ 1 h since lastAlertAt → post a "still down" reminder and re-stamp.
- Success: if it was down (i.e., had crossed the threshold) → post the recovery alert; reset
lastAlertAt to 0.
The threshold ("alert after 1/2/3 consecutive failures") therefore
debounces flapping: with threshold 2, a single blip produces neither a
down nor a recovery notification, because the target never entered the
alerted-down state.
Notifications go to one of three channels —
watchtower_down (importance HIGH),
watchtower_up (DEFAULT), watchtower_quiet
(LOW) — and quiet hours simply reroute whatever would have been posted
onto the LOW channel, so night-time alerts still land in the shade but
never make sound or vibrate. Because channels are the routing unit,
the user can further override sound/importance per alert kind in
Android's own settings. All alerts for a target reuse
target.id as the notification id, so a recovery replaces
the outstanding down alert instead of stacking.
UI and Theming
The web theme is reimplemented natively rather than webview'd:
- Backdrop — a
Box with a vertical gradient, plus two Canvas layers: a static one drawing the radial cyan glow and 4 dp-pitch scanlines (drawn once, never recomposes), and an animated one drawing the sweep band, driven by a rememberInfiniteTransition float over 11 s. Splitting them keeps the per-frame cost to a single gradient rect.
- Holo cards —
Column with translucent steel background and 1 dp border; the signature cyan corner brackets are drawn in drawWithContent as four stroke lines pinned to the top-left/bottom-right corners, so they scale with any card size.
- Typography — Chakra Petch and IBM Plex Mono ship as bundled font resources (no runtime font fetching); the title glow is a
Shadow with 18 px blur in the text style.
- Charts — hand-rolled on
Canvas: the sparkline is a normalized polyline of latency; the detail chart adds grid lines, a gradient area fill under the latency path, and red dots pinned to the baseline for failed checks. Uptime %, average latency, and the event log (up/down transitions) are computed in remember blocks from the same result list the chart uses.
- Edge-to-edge — target SDK 35 forces it, so the gradient backdrop runs under the system bars and content is inset with
WindowInsets.safeDrawing padding.
Reactive Flow, End to End
One example tying it together: the background worker checks a target
→ repository inserts a CheckResult row → Room
invalidates the observeSince Flow → the ViewModel's
StateFlow emits → the dashboard recomposes the card's
status dot, latency text, uptime %, and sparkline — while the same
insert may have posted a notification through the state machine. There
is exactly one write path and one read path; nothing polls and no
screen holds its own copy of state.