Technical Documentation

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:

  1. Before running a check, fetch the last threshold + 1 non-skipped results. Count leading consecutive failures → wasDown = failures ≥ threshold.
  2. Run the check, insert the result.
  3. 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.
  4. 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.