Store plumbing — RevenueCat, AdMob, OneSignal, and the three stores
What is wired, what each dashboard has to produce, and where each value goes. Written 2026-09-02 for the Shipaton push: plumbing first, testers next.
Live updates (Capgo)
App: HALL PASS, com.verybad.hallpass, in the HALL PASS organization.
Kept on purpose (Chris, 2026-10-08). Store releases are regular, but OTA
stays for fast fixes and for players who never update the app. Hall Pass is
a Shipaton finalist, so expect traffic. Do not remove the updater.
@capgo/capacitor-updater stays on the v8 line for Capacitor 8.
capacitor.config.json uses autoUpdate: "onlyDownload", with the production
channel and a version matching package.json. The documented atBackground
mode also applies updates on backgrounding, which can interrupt a suspended
run. Download-only leaves activation to src/native-updates.js, initialized
by src/native.js behind isNative() using window.Capacitor globals.
The game acknowledges notifyAppReady() once after successful Game boot.
Downloads are saved as pending IDs in a separate localStorage key. Only an ID
already pending when a new WebView boots can be applied, and only at the title
screen. Foregrounding a suspended app does not count as a new boot. Run, pause,
level-up and boss-drop screens defer activation until title. Title input is
held while the native bridge switches files; failed switches restore it.
Failed/rolled-back candidates are not retried in a loop. No native next() or
background activation is scheduled, and saved runs/settings are untouched.
The OTA module is compiled into dist-app only. npm run build:prod removes
it at build time, preserving the existing web bundle byte for byte at the same
version/commit. build:app builds that normal site, then builds the native
bundle and gathers its runtime assets into dist-app/.
Authenticate and upload
Run npx @capgo/cli@latest init --no-analytics for guided setup. A new account
must finish organization onboarding before browser CLI login has an eligible
organization. This account's setup key can list HALL PASS, but the
init wizard requests org.create_app even after the dashboard has created it;
complete existing-app setup manually rather than broaden permissions merely
to satisfy that step. Login credentials stay in the CLI's user-level .capgo
store, outside the repository. Never put keys in source, scripts or docs.
For a new app, create the production channel once and enable both platforms:
npx @capgo/cli@latest channel add production com.verybad.hallpass --default,
then npx @capgo/cli@latest channel set production com.verybad.hallpass --ios --android.
This is already done for HALL PASS. The dashboard's channel switches are the
cross-check: CLI 8.50.3's list table showed platform crosses even while both
dashboard switches were checked after a successful set.
For each new release, choose an unused semver in package.json and its lockfile,
and match plugins.CapacitorUpdater.version in capacitor.config.json, then:
npm test
npm run ota
ota runs npm run build:app, then uploads dist-app as that package version
to production, with --fail-on-incompatible. It does not build a native
binary, deploy Netlify, or publish an APK. The equivalent upload command is:
npx @capgo/cli@latest bundle upload com.verybad.hallpass --path dist-app --bundle <package-version> --channel production --fail-on-incompatible
Capgo versions are unique; do not reuse one, even after deletion. Keep the compatibility check enabled. A plugin/native dependency change needs a native release rather than an incompatible OTA upload. A first bundle at the same version as the embedded native payload is a baseline; use a newer version for an actual device update. Verify the channel's bundle in Capgo after uploading.
Native bootstrap and Mac handoff
Existing APKs cannot acquire the updater plugin via OTA. It becomes available
after a new native release includes it. Do not publish another tester APK until
Chris requests a major release. Android debug compilation for verification is
separate from publishing; preserve the existing tester APK because the web
build retains dist/hallpass-playtest.apk before clearing the output and restores
the same bytes afterward. It never picks up incidental local Gradle output.
Only for a user-authorized APK release, build the new binary and run
npm run build:prod -- --refresh-playtest-apk to replace the download from
android/app/build/outputs/apk/debug/app-debug.apk; the flag fails if that file
is missing. A fresh checkout can still build without an APK, but production
prints a warning: restore the published APK into dist/ before deploying the site.
On Windows: npm run build:app, npx cap sync android, npx cap copy ios.
Never cap sync ios on Windows: its paths can corrupt the Swift package.
On the Mac: npm ci, npm run build:app, npx cap sync ios, then open Xcode,
verify the updater dependency/signing, and archive for TestFlight/App Review.
Before release, verify on a device: download while playing, background/resume
without switching, quit and relaunch, reach title and switch, then confirm the
new bundle is acknowledged. Also check offline boot and rollback behavior.
Routine JS/CSS/assets fixes use OTA within store rules. Native code, SDK, entitlement and plist changes require a new native build and the applicable store review; OTA does not waive review rules for material app changes.
References: update modes, updater API, bundle upload CLI.
Initial verification — 2026-09-10
npm test: 568 passed, including 11 OTA safety tests.npm run build:appand the fullnpm run otaflow passed.- Web bundle SHA-256 remained
37b46e150ea7c2adc7efd3483b6eab0d1338b2dd596f4700079177c81dafb53dbefore/after integration at source HEADd111816and version0.1.0. - Android sync, final debug compilation with JDK 21, and iOS copy passed. No Windows iOS sync or Mac archive was performed.
- Capgo accepted 0.1.0 (about 120 MB compressed) and the CLI registry
confirmed it as the production channel's bundle. The dashboard confirmed
default delivery and both iOS/Android enabled. Upload archive SHA-256:
39c5b9862de68d5d7aacd07d490a0a998fff8ffb6253c6a32d2ba134dd2dc94b. - No Netlify deploy or tester APK publication. The existing tester APK was
restored after compilation; its SHA-256 remains
44d49d320e8caf19f2b4dae6981a760b8e865a55dc6d15677b880e1286203c3b. - Physical-device cold-start/background/rollback testing remains part of the native release handoff above. This organization is on its initial Capgo trial.
What the game does now (all of it dark until an id is real)
| Surface | Where | Behaviour |
|---|---|---|
| THE CHARTER'S FAVOR | death screen | REVIVE — WATCH AN AD. One per night, never in Tonight's Run, only when an ad is loaded. Back at half health, the bell moves +1:00, and 30% of the unbanked haul is dropped (the rest is pending again). The screen states the numbers before the tap. |
| SECOND HEART | blueprint sheet, before a run | Insurance per room: fragments (40 + 20 per hour of the night) or a rewarded ad (insurance placement). One per room per run; hides the Second Heart evolution card. |
| REROLL | level-up screen | A fresh hand: two free per local day, then fragments or a rewarded ad (reroll placement). Free only in Tonight's Run. |
| Top of the hour | the NEXT button on a win | One interstitial between hours, at most one per 3 minutes, never after First Bell, never for a supporter, never on a fresh save's first win. |
| PATRON | Clubhouse → Store; also native Options → Patron | supporter (one-time: ads off, badge), boost_7d (1.5× banked Coins for 7 days), and boost_30d (2× for 30 days). Consumable timed boosts; no subscription or immediate coin grants. |
| Analytics | everywhere, web included | PostHog, project 606098 (US). Named events — app_opened, run_started, run_ended (with perf_* frame-rate fields and device_model), insurance_bought, reroll_used, favor_offered/taken/abandoned, purchase, perf_recording (the manual recorder's summary) — plus one $pageview per boot (referrer, UTM, country: the Web Analytics traffic view) and $web_vitals. Autocapture and session replay are OFF on purpose. Identity is the club card. Player switch at Options → Club → Gameplay Analytics. |
| Tonight's Run at 9 PM | OneSignal | Permission asked once, after the first cleared hour. Tags: student, hour_reached, nights_cleared, supporter, tz_offset, revived. Identity = the club card. |
Source: src/services.js (ids), src/iap.js, src/ads.js, src/push.js, wired from src/native.js. Every call sits behind isNative(); the web build is unchanged.
Dashboards — the checklist, in order
1. RevenueCat (project → app per platform)
Done 2026-09-10: project HALL PASS (ff995420, category Games, platform Capacitor). App Store app appcf2384d34c (bundle com.verybad.hallpass, reusing the account's 2024 In-App Purchase key GL7C87LSS, verified valid). Public SDK key appl_fZDxTUguoVrgcxlGVopwfVUgsFu is in src/services.js. Products supporter / boost_7d / boost_30d; entitlement supporter ← supporter; offering default with packages supporter, boost_7d, boost_30d. Not done: Play Store app (needs service-account JSON), Galaxy Store app (needs Seller Portal), App Store Connect API key upload (optional; enables product import and price sync). The Ads page needs no dashboard setup: the SDK adapter (our ad-tracker bridge) is the integration.
- Create the project HALL PASS. Add an iOS app (bundle id
com.verybad.hallpass) and an Android app (packagecom.verybad.hallpass). - Copy each app's public SDK key (
appl_…,goog_…) intoSERVICES.revenuecat.ios/.android. - Products (create in App Store Connect and Play Console first, then import):
supporter— non-consumable, $2.99 (ads off + club-card badge)boost_7d— consumable, $0.99: fragments bank at 1.5x for 7 daysboost_30d— consumable, $1.99: fragments bank at 2x for 30 days (Chris, 2026-09-10: timed multipliers replaced the legacy coin packs. App Store Connect has all three configured: price, all countries, en-US localization. Apple ID 6810716450, app name "HALL PASS: Survive the Night" because plain "HALL PASS" is taken. Still needed per IAP: a review screenshot.) The Patron screen uses RevenueCat's localized price strings, hides boosts at or below the player's active tier, and shows time remaining. Consumable boosts extend active time and retain the higher multiplier; cloud save merge takes the later expiry record. Restore handles the permanent supporter entitlement; consumed boost time follows the club-card save, not an entitlement.
- Entitlement
supporter← attach thesupporterproduct. - Offering
defaultwith three packages: supporter, boost_7d, boost_30d. The game reads the product identifier (boost_7d→ 1.5x for 7 days,boost_30d→ 2x for 30 days). - Ads page → connect the AdMob account and enable impression-level ad revenue in AdMob (Catvertising needs this).
2. AdMob
Done 2026-09-10 (publisher pub-4628409330036601, account verification pending): iOS app ~7869662361 (rewarded /9458076201, interstitial /4354300633); Android app ~6537610844 (rewarded /5227187156, interstitial /7659120821). Pasted into src/services.js, the manifest and Info.plist. testing is still true: flip it for the store build and add Chris's phone as a test device first. Link the App Store listing in AdMob once the app is live ("Add stores").
- Create the app on both platforms; copy the App IDs into
SERVICES.admob.{ios,android}.appand into the native config:android/app/src/main/AndroidManifest.xml(com.google.android.gms.ads.APPLICATION_ID) andios/App/App/Info.plist(GADApplicationIdentifier). They ship with Google's sample ids today. - Two ad units per platform: Rewarded (revive) and Interstitial (top of hour). Paste into
SERVICES.admob.*.rewarded / .interstitial. - Set
SERVICES.admob.testing = falsefor the store build. Add your own device as a test device in AdMob so you can keep testing live units without a policy strike.
2b. PostHog
Already live: project 606098, US Cloud, token in SERVICES.posthog.key
(write-only, safe in a public app). Nothing further is required to collect.
Worth doing in the dashboard once events arrive:
- Funnel — "Clear rate by room", saved and on the starter dashboard:
https://us.posthog.com/project/606098/insights/hGadvo8g
run_started→run_ended, step 2 filtered tooutcome = cleared, broken down byroom, last 30 days. The filter is the whole point: every run that starts also ends, so an unfiltered funnel reads ~100% in every room and answers nothing. With it, the room with the lowest rate is the one stopping people. 1b. Trends — "Attempts by room and outcome", saved and on the same dashboard: https://us.posthog.com/project/606098/insights/0kuDBg6xrun_endedcounted, double breakdown byroomandoutcome, bar chart. The funnel above counts PEOPLE; this counts ATTEMPTS. A player who fails the boiler room four times and clears on the fifth is one success in the funnel but 4 died / 1 cleared here — the funnel says who eventually gets through, this says how expensive it was. Read them as a pair. (PostHog gotcha: formula modeA / Breturns no rows when combined with a breakdown, so the rate is read off the two bars rather than computed.) - Funnel —
run_ended (died)→favor_offered→favor_taken, and the same forinsurance_boughtandreroll_usedbykind(free / frag / ad). This is the conversion evidence the HAMM and Catvertising submissions ask for, and it is the one number a judge will want. - Retention on
app_opened, to see whether the 9 PM push moves it. - Set
run_endedandpurchaseas the project's key events so the dashboard opens on them.
3. OneSignal
Done 2026-09-10: org Web Royal LLC, app HALL PASS, App ID 108a554c-18ed-4997-b44b-407f7153961f in src/services.js. Still needed: iOS APNs key upload (Mac) and Android FCM service account (needs Firebase project) in OneSignal → Settings → Platforms; the 9 PM scheduled push itself.
- Create the app; copy the App ID into
SERVICES.onesignal.appId. - Android: OneSignal needs Firebase Cloud Messaging. Create a Firebase project, add the Android app (
com.verybad.hallpass), downloadgoogle-services.jsonintoandroid/app/, and paste the FCM service-account JSON into OneSignal's Android settings. The Gradle files already apply the plugin when the file exists. - iOS (on the Mac, in Xcode): upload the APNs key to OneSignal; on the App target add Push Notifications and Background Modes → Remote notifications; add an App Group
group.com.verybad.hallpass.onesignal; add a Notification Service Extension target namedOneSignalNotificationServiceExtension.capacitor.config.jsonalready setsios.handleApplicationNotifications: false. - The message: a scheduled push, delivered with timezone delivery at 21:00. Since 2026-09-17 it also carries the nightly free pull, so it is ONE message, not two: title "TONIGHT'S RUN IS LIVE — {modifier}", body "Your free pull is in. One card, on the house, until midnight." Set Additional Data to
{ "open": "deck" }so a tap opens the Deck from the title (game.openFrom). Segment: everyone withhour_reached ≥ 1. The game tagsfree_pull_day(local date) when a pull is claimed, so a follow-up segment "free_pull_dayis not today" can nudge only the players who have not claimed. The web build has no push SDK: web players find the pull on the title's Deck button. That single thoughtfully timed push is what the Keep Them Coming Back category asks for; a Journey (e.g.revived = 1and no session in 3 days) is the stand-out.
4. Stores
- Google Play: create the app, upload the AAB to a closed test track. A new personal developer account must run a closed test with 12+ testers for 14 days before production access — start that clock immediately. Set up the three in-app products with the ids above.
- App Store Connect: create the app, the three in-app purchases (same ids), and a TestFlight build. Capability In-App Purchase on the target.
- Galaxy Store: the same Android build; enable the Samsung IAP only if you want Galaxy-native purchases (RevenueCat now lists Galaxy Store as a native app type in the dashboard (2026-09-10), so add a Galaxy app to the project and use Samsung IAP; project id ff995420).
Native config already applied here
- Android
launchMode→singleTop(RevenueCat:singleTaskcancels purchases during payment verification). - Android manifest: AdMob
APPLICATION_IDmeta-data (sample id),com.google.android.gms.permission.AD_ID,POST_NOTIFICATIONS. ios/generated withnpx cap add ios— sync and build on the Mac (npx cap sync ios, then Xcode). Info.plist carriesGADApplicationIdentifier(sample id),NSUserTrackingUsageDescription, and the Google SKAdNetwork id; add the full SKAdNetwork list from AdMob's iOS guide before release.
Catvertising — the bridge that makes them RevenueCat Ads
RevenueCat's ad tracking (AdTracker) is native-only — iOS and Android SDKs, not Capacitor. The game closes that gap with a local plugin, plugins/hallpass-ad-tracker: the AdMob plugin already emits every event JavaScript needs (loaded, failed, shown, and the paid event with impression-level revenue), src/ads.js forwards each one with its placement (revive, top-of-hour) and a per-load impression id, and the bridge calls trackAdLoaded / trackAdDisplayed / trackAdFailedToLoad / trackAdRevenue. Android is compiled and verified in this repo's Gradle build; the Swift twin is written against purchases-ios 5.85.0 and compiles on the Mac. It resolves {tracked:false} until the RevenueCat key is real, so nothing depends on it.
Tracking prompt removed (2026-09-10): src/ads.js makes no ATT request.
iOS skips UMP and AdMob startup entirely, so both ad placements are unavailable
while purchases remain enabled. This also protects installed shells: Google's
remote IDFA consent message can independently show ATT. The AdMob dashboard
could not be checked because browser security policy blocked access; its remote
settings have not been changed or verified.
Android still runs Google's required regional consent flow. Only an explicit
canRequestAds: true permits SDK initialization and ad loads; SDK/form failures
and missing decisions keep ads off. Both ad formats always request npa: true,
including reloads. Non-personalized ads still have consent/data-use obligations.
Before re-enabling iOS ads, disable the AdMob IDFA message, verify a native
configuration without cross-app tracking, and test both fresh installs and
devices that previously authorized tracking. The existing unused
NSUserTrackingUsageDescription key can be removed in the next Mac native
release; changing that plist cannot be delivered by OTA. This release uses
the existing native SDKs and ships its JavaScript guard through Capgo, applied
only on a later cold boot at title. No APK is needed or published.
The app shell ships only the game
dist/ is the whole site — docs hub, the 15 MB single-file build, sound library, showroom, source maps. npm run build:app builds it and then copies only what the bundle loads into dist-app/ (index, bundle, and six asset folders; 53 MB instead of 133), which is what capacitor.config.json now points at. Icons and splash are generated from resources/ by npx @capacitor/assets generate --assetPath resources.
Build and verify
npm run build:prod
npx cap sync android
JAVA_HOME=G:/Skunkworks/tools/jdk21 android/gradlew -p android assembleDebug
For the store: npm run build:app, npx cap sync android, then bundleRelease — it builds unsigned today; Play needs it signed with an upload key (see the signing section in android/app/build.gradle).
The debug APK lands at android/app/build/outputs/apk/debug/app-debug.apk. With the sample AdMob ids, testers see real test creatives on the revive and between hours. RevenueCat and OneSignal stay dark until their ids are pasted — nothing breaks, the buttons simply do not appear.