通行証 · HALL PASS DOCS · PLAY

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

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.

  1. Create the project HALL PASS. Add an iOS app (bundle id com.verybad.hallpass) and an Android app (package com.verybad.hallpass).
  2. Copy each app's public SDK key (appl_…, goog_…) into SERVICES.revenuecat.ios / .android.
  3. 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 days
    • boost_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.
  4. Entitlement supporter ← attach the supporter product.
  5. Offering default with 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).
  6. 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").

  1. Create the app on both platforms; copy the App IDs into SERVICES.admob.{ios,android}.app and into the native config: android/app/src/main/AndroidManifest.xml (com.google.android.gms.ads.APPLICATION_ID) and ios/App/App/Info.plist (GADApplicationIdentifier). They ship with Google's sample ids today.
  2. Two ad units per platform: Rewarded (revive) and Interstitial (top of hour). Paste into SERVICES.admob.*.rewarded / .interstitial.
  3. Set SERVICES.admob.testing = false for 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:

  1. 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 to outcome = cleared, broken down by room, 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/0kuDBg6x run_ended counted, double breakdown by room and outcome, 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 mode A / B returns no rows when combined with a breakdown, so the rate is read off the two bars rather than computed.)
  2. Funnel — run_ended (died) → favor_offered → favor_taken, and the same for insurance_bought and reroll_used by kind (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.
  3. Retention on app_opened, to see whether the 9 PM push moves it.
  4. Set run_ended and purchase as 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.

  1. Create the app; copy the App ID into SERVICES.onesignal.appId.
  2. Android: OneSignal needs Firebase Cloud Messaging. Create a Firebase project, add the Android app (com.verybad.hallpass), download google-services.json into android/app/, and paste the FCM service-account JSON into OneSignal's Android settings. The Gradle files already apply the plugin when the file exists.
  3. 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 named OneSignalNotificationServiceExtension. capacitor.config.json already sets ios.handleApplicationNotifications: false.
  4. 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 with hour_reached ≥ 1. The game tags free_pull_day (local date) when a pull is claimed, so a follow-up segment "free_pull_day is 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 = 1 and no session in 3 days) is the stand-out.

4. Stores

Native config already applied here

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.

HALL PASS by Very Bad Games · Solo Developer Chris Sands · All documentation