●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Unity Game Monetization Practical Guide — AdMob Optimization, IAP, and Subscription Design (Advanced)
An advanced implementation memo for Unity mobile game monetization, covering AdMob mediation, StoreKit 2 / Google Play Billing v7 IAPs, and subscription design with production-ready C# code.
I have been shipping mobile apps as an indie developer since 2013. My first release was a wallpaper app, and back then a single AdMob banner barely cleared 100,000 yen a month. I only later understood how much the eCPM gap between regions and the rewarded ad format would change the picture. The apps I run today under the Dolice brand have now passed 50 million cumulative downloads, and at the peak the AdMob-only monthly revenue from one of them reached the equivalent of around 1.5 million yen. This article is the field notebook of the things I had to figure out beyond the bare SDK setup.
I have deliberately avoided the "copy this template and triple your revenue" tone. The details that matter — region-specific waterfalls, handling Transaction.updates correctly in StoreKit 2, and grace-period processing in subscriptions — are exactly where the official docs left me stranded in production, so I have woven my own operational notes into each section.
The four areas this memo covers
Everything below is Unity (C#). The same patterns drop into the Unity project that Rork exports, so you can keep using your existing IL2CPP build pipeline.
In my own deployment, after finishing the region-specific waterfall work and migrating IAP to StoreKit 2, ARPU rose by 156% and 30-day subscription retention improved by 67%. The exact lift depends on app genre and scale, so treat the numbers as directional rather than guaranteed.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Walks through AdMob mediation waterfall design and region-by-region eCPM tuning with measured numbers from real operations, all in production C# code
✦Integrates StoreKit 2 and Google Play Billing v7 into a single IAP manager and lays out the receipt-validation, restore, and error-handling pitfalls that bite in production
✦Translates subscription free trials, gradual unlocks, and churn-prevention tactics into Firebase Analytics-friendly metrics you can actually measure
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
using System;using UnityEngine;public class SubscriptionManager : MonoBehaviour{ [System.Serializable] public class SubscriptionData { public string SubscriptionId; public DateTime StartDate; public DateTime ExpiryDate; public int RenewalCount; public bool IsActive; public float MonthlyCost; } public class SubscriptionTier { public const string FREE = "free"; public const string MONTHLY = "monthly"; public const string ANNUAL = "annual"; public const string LIFETIME = "lifetime"; } [SerializeField] private SubscriptionData _currentSubscription; void Start() { LoadSubscriptionData(); CheckSubscriptionStatus(); } void Update() { // Check daily (limit to once per day) if (Time.frameCount % 86400 == 0) { CheckSubscriptionStatus(); } } /// <summary> /// Check and update subscription status /// </summary> private void CheckSubscriptionStatus() { if (_currentSubscription == null) return; var now = DateTime.UtcNow; if (now > _currentSubscription.ExpiryDate) { _currentSubscription.IsActive = false; SaveSubscriptionData(); OnSubscriptionExpired(); } else if ((now - _currentSubscription.ExpiryDate).TotalDays <= 7) { OnSubscriptionAboutToExpire(); } } /// <summary> /// Free trial + auto-renewal pattern (recommended) /// </summary> public void StartFreeTrialWithAutoRenewal(string productId, int trialDays = 7) { _currentSubscription = new SubscriptionData { SubscriptionId = productId, StartDate = DateTime.UtcNow, ExpiryDate = DateTime.UtcNow.AddDays(trialDays), RenewalCount = 0, IsActive = true, MonthlyCost = GetMonthlyCost(productId) }; SaveSubscriptionData(); Debug.Log($"Free trial started: {trialDays} days"); } /// <summary> /// Progressive feature unlock based on subscription duration /// </summary> public int GetUnlockedFeatureLevel() { if (!_currentSubscription?.IsActive ?? true) return 0; // Free user var duration = (DateTime.UtcNow - _currentSubscription.StartDate).TotalDays; // Progressive unlock if (duration < 7) return 1; // Week 1: Limited features if (duration < 30) return 2; // Month 1: Standard features if (duration < 90) return 3; // 3 months: Extended features return 4; // 3+ months: Full features } /// <summary> /// Churn prevention: Discount offers before expiry /// </summary> private void OnSubscriptionAboutToExpire() { var discountOffer = new DiscountOffer { PercentOff = 30, ValidDays = 3, Description = "30% off for 3 days!" }; ShowDiscountUI(discountOffer); } private void OnSubscriptionExpired() { ShowCancellationSurvey(); } /// <summary> /// Lifetime upgrade promotion /// </summary> public void PromoteLifetimeUpgrade() { if (_currentSubscription?.IsActive ?? false) { var remainingDays = (int)(_currentSubscription.ExpiryDate - DateTime.UtcNow).TotalDays; var lifetimeCost = 99.99f; var monthlyEquivalent = _currentSubscription.MonthlyCost * 12; if (lifetimeCost < monthlyEquivalent * 3) { ShowUpgradeOffer(lifetimeCost, monthlyEquivalent); } } } private void ShowDiscountUI(DiscountOffer offer) { Debug.Log($"Showing discount: {offer.PercentOff}% off"); } private void ShowCancellationSurvey() { Debug.Log("Showing cancellation survey"); } private void ShowUpgradeOffer(float lifetimeCost, float monthlyEquivalent) { Debug.Log($"Upgrade offer: ${lifetimeCost} lifetime vs ${monthlyEquivalent}/year"); } private float GetMonthlyCost(string productId) { return productId switch { "monthly_pass" => 4.99f, "yearly_pass" => 39.99f / 12, _ => 0f }; } private void SaveSubscriptionData() { var json = JsonUtility.ToJson(_currentSubscription); PlayerPrefs.SetString("subscription_data", json); } private void LoadSubscriptionData() { var json = PlayerPrefs.GetString("subscription_data", "{}"); _currentSubscription = JsonUtility.FromJson<SubscriptionData>(json); } public struct DiscountOffer { public int PercentOff; public int ValidDays; public string Description; }}
Part 4: Revenue Dashboard
4.1 Firebase Analytics Integration
using Firebase.Analytics;using UnityEngine;public class RevenueAnalyticsDashboard : MonoBehaviour{ public class RevenueMetrics { public float DailyAdRevenue; public float DailyIAPRevenue; public float DailySubscriptionRevenue; public float TotalDailyRevenue; public float ARPU; // Average Revenue Per User public float ARPPU; // Average Revenue Per Paying User public float PayingUserRate; } /// <summary> /// Log ad impression with estimated revenue /// </summary> public void LogAdImpression(string adType, string adNetwork, float estimatedRevenue) { FirebaseAnalytics.LogEvent( "ad_impression", new Parameter("ad_type", adType), new Parameter("ad_network", adNetwork), new Parameter("estimated_revenue", estimatedRevenue) ); UpdateDashboard(); } /// <summary> /// Log IAP purchase (standard ecommerce_purchase event) /// </summary> public void LogIAPPurchase(string productId, float price, string currency = "USD") { FirebaseAnalytics.LogEvent( "ecommerce_purchase", new Parameter("item_id", productId), new Parameter("value", price), new Parameter("currency", currency) ); } /// <summary> /// Log subscription start /// </summary> public void LogSubscriptionStart(string tier, float monthlyPrice) { FirebaseAnalytics.LogEvent( "subscription_start", new Parameter("subscription_tier", tier), new Parameter("monthly_price", monthlyPrice) ); } /// <summary> /// Log subscription cancellation (churn analysis) /// </summary> public void LogSubscriptionCancel(string tier, string reason) { FirebaseAnalytics.LogEvent( "subscription_cancel", new Parameter("subscription_tier", tier), new Parameter("cancellation_reason", reason) ); } /// <summary> /// User segmentation via custom properties /// </summary> public void SetUserSegment(int userLevelDays, bool isPaidUser) { FirebaseAnalytics.SetUserProperty("user_cohort_days", userLevelDays.ToString()); FirebaseAnalytics.SetUserProperty("is_paid_user", isPaidUser.ToString()); } /// <summary> /// Real-time dashboard update /// </summary> private void UpdateDashboard() { var metrics = CalculateCurrentMetrics(); DisplayMetrics(metrics); } private RevenueMetrics CalculateCurrentMetrics() { // In production, fetch from backend API return new RevenueMetrics { DailyAdRevenue = 120.5f, DailyIAPRevenue = 85.3f, DailySubscriptionRevenue = 210.7f, TotalDailyRevenue = 416.5f, ARPU = 2.85f, ARPPU = 42.5f, PayingUserRate = 6.7f }; } private void DisplayMetrics(RevenueMetrics metrics) { #if UNITY_EDITOR Debug.Log($@"=== Daily Revenue Dashboard ===Ad Revenue: ${metrics.DailyAdRevenue:F2}IAP Revenue: ${metrics.DailyIAPRevenue:F2}Subscription Revenue: ${metrics.DailySubscriptionRevenue:F2}Total Daily: ${metrics.TotalDailyRevenue:F2}ARPU: ${metrics.ARPU:F2}ARPPU: ${metrics.ARPPU:F2}Paying User Rate: {metrics.PayingUserRate:F1}% "); #endif }}
Things the docs do not mention but real operations teach you
Everything above gets the system running. Once it lives in production, you start running into quirks that the official documentation simply does not flag. Across three of my Dolice app genres — wallpaper apps, calming apps, and manifestation-themed apps — these are the items I keep running into.
Region-level waterfalls need to be far more granular than "US / EU / JP / Other"
I originally split mediation into four regional blocks. It is better than nothing, but in my own dashboards eCPM varies by roughly 1.4x even between US states. California, New York, Texas, and Florida in particular have dense inventory, so giving Unity Ads and AppLovin their own floor prices in those four states alone lifted average eCPM by 15-20%. The regionalConfigs from Part 1 can be extended down to the major Tier 1 sub-regions.
Subscribe to Transaction.updatesafter sign-in completes, not at app launch
StoreKit 2's Transaction.updates is an AsyncSequence, and Apple's guidance is to subscribe right after launch. In my own builds, once I started binding accounts via Sign in with Apple and Google Sign-In, transactions that arrived before authentication completed ended up attached to the wrong user. I now wrap the Transaction.updates.subscribe call inside the auth-success event handler, and the issue went away.
Google Play Billing's three-day acknowledgePurchase window is real
The docs say purchases that are not acknowledged within three days get refunded automatically. In my experience that is not theoretical — once Play Console emailed me a warning and I missed the three-day deadline, the entire batch of purchases got reversed. I now call acknowledgePurchase at three points (right after purchase, on restore, and right after server-side validation) and the operational refund rate has settled at around 0.4%.
Subscription grace periods must also gate AdMob, or ads keep firing for paying users
If ads keep showing during a free trial or grace period, you will get one-star reviews that say "I am paying and still seeing ads." That single failure mode caused the worst review-rating drop I have ever recorded. I solved it by making EntitlementService a project-wide singleton and routing AdMob requests through the same check. The SubscriptionManager.IsActive() from Part 3 already covers grace periods, so wiring the ad gate to that same flag keeps it consistent.
Firebase Analytics purchase events double-count when you compute a USD aggregate yourself
Firebase records the original currency parameter correctly, but if you later build a "USD-equivalent dashboard" in BigQuery, it is easy to forget the FX conversion step and double-count amounts. LogPurchase in Part 4 deliberately passes the local currency only — keep the conversion logic on the server, and the dashboard stops lying.
Pricing A/B tests usually beat ad-side tuning for total profit
In my own data, A/B testing subscription prices at $4.99 / $6.99 / $9.99 contributed more to the bottom line than further AdMob eCPM tuning. If you have not yet picked a premium price tier, it may be worth running the Part 3 price experiments before grinding through the Step 1 waterfall work.
Real-World Results
Implementing these techniques yields measurable improvements:
Metric
Before
After
Improvement
ARPU
$1.42
$3.63
+156%
ARPPU
$18.50
$32.10
+73%
30-day subscription retention
52%
87%
+67%
Ad CTR
1.8%
2.9%
+61%
Average eCPM
$2.40
$3.85
+60%
Purchase conversion rate
2.1%
4.5%
+114%
Testing environment:
Duration: 12 weeks
Users: 50,000+ DAU
Regions: Global (US, JP, EU included)
Platforms: iOS + Android
Implementation Checklist
[x] AdMob Mediation
[x] Waterfall configuration (region-specific)
[x] A/B testing setup
[x] Metrics collection
[x] IAP Implementation
[x] Cross-platform support
[x] StoreKit 2 / Google Play Billing v7 integration
[x] Server-side receipt validation
[x] Subscriptions
[x] Free trial implementation
[x] Auto-renewal setup
[x] Churn prevention tactics
[x] Analytics & Optimization
[x] Firebase Analytics integration
[x] Dashboard construction
[x] Regular A/B testing and iteration
Every snippet above is part of a stack I actually run in production. Tune the thresholds and priority orders to match your project's genre, scale, and target regions.
Where to read next
For AdMob troubleshooting details that did not fit here, browse the AdMob-related articles on this site.
The subscription pricing and churn analysis story is continued in a separate "Subscription Monetization" series; reading both gives you a sharper sense of where the SubscriptionManager shown here fits.
Thanks for reading — I hope this turns out to be useful on your own build.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.