Monetization¶
Doriax has three service classes for earning from a game. They are static classes that any script can call, and each one talks to a platform SDK that an export includes only when it is enabled in Project Settings:
| Class | Service | Platforms | Enabled by |
|---|---|---|---|
AdMob |
Google Mobile Ads: banner, interstitial, rewarded, rewarded interstitial and app open ads, with Google's consent form | Android, iOS | Google AdMob, in the Android and iOS sections |
InAppPurchase |
Google Play Billing and the App Store (StoreKit 2): one-time products and subscriptions | Android, iOS | Google Play Billing in the Android section, App Store Purchases in the iOS section |
WebPortal |
The SDK of one web game portal: ads, gameplay and loading events, cloud saves | Web | Game Portal, in the Web section |
The classes exist on every platform, so the same scripts build and run everywhere. Where a service is missing (desktop, the editor's Play, an export with the setting off), calls that expect an answer report "not available" through their usual event, and the first call logs a warning naming the setting to turn on.
How results arrive¶
Every result arrives as an event, never as a return value: an ad loaded, a purchase went through, a portal finished starting. The platform answers on its own thread. The engine queues each answer and runs the subscribers at the start of the next frame, on the game thread and before the scenes update, so a handler can use the scene like any other script code.
Subscribe with the same helpers as any other event:
// Ads.h
#include "ScriptBase.h"
#include "AdMob.h"
class Ads : public doriax::ScriptBase {
public:
Ads(doriax::Scene* scene, doriax::Entity entity);
~Ads();
void onAdLoaded(doriax::AdMobFormat format);
};
// Ads.cpp
Ads::Ads(Scene* scene, Entity entity): ScriptBase(scene, entity) {
REGISTER_EVENT(AdMob::onAdLoaded, onAdLoaded);
}
Ads::~Ads() {
UNREGISTER_EVENT(AdMob::onAdLoaded, onAdLoaded);
}
void Ads::onAdLoaded(AdMobFormat format) {
}
- A C++ handler takes the parameters exactly as the event declares them, by value
(
std::string, notconst std::string&), or the macro does not compile. - The Code Editor's Add event menu lists these events under AdMob, In-App Purchase and Web Portal, and writes the registration and the handler for you.
- The events belong to the class, not to a scene, so they keep firing across scene
changes. A Lua script's
RegisterEventsubscriptions are removed with the script, and a C++ script removes its own withUNREGISTER_EVENT. A function assigned straight to an event (AdMob.onAdLoaded = function(format) ... end) stays until the game shuts down.
Ads pause the game¶
While a full-screen ad covers the game, the engine pauses just as when the app goes to
the background: sounds pause, the update loop stops, and
Engine::onPause fires.
Engine::onResume follows when the ad closes.
The same happens while a banner's click opens a page over the game, and on the web while
a portal ad plays or a portal pauses the game itself.
On Android the whole app pauses while a full-screen ad shows, so the events of that ad
(onAdShown, onAdImpression, onUserEarnedReward, ...) arrive together when it closes.
AdMob¶
Setup¶
- Create the app and its ad units in the AdMob console.
- In Project Settings → Platforms, open the Android and iOS sections, tick
Google AdMob, and paste the AdMob App ID (
ca-app-pub-…~…). Left empty, the export uses Google's sample app, which only serves test ads. - On Android, declare in the Play Console that the app contains ads and uses the advertising ID. Exports raise Min SDK to 24 for AdMob.
- On iOS, fill in Tracking Description when your consent message asks for tracking permission.
- Export as Source Code and build with Android Studio or Xcode. Ads only run on a device, emulator or simulator; the editor has no AdMob.
See Android and iOS settings for what each field writes.
Consent, then initialize¶
Google's policies require a consent message for users in the EEA, the UK and
Switzerland, and privacy messages for some US states. AdMob shows both through Google's
User Messaging Platform, so a game asks first and starts Google Mobile Ads after:
- Call
requestConsent()at every launch. It updates the consent information and shows Google's consent form when the user still has to answer. onConsentUpdatedreports the result (errorCode0 on success). WhencanRequestAds()is true, callinitialize(). Consent given in an earlier launch counts, even if this request failed offline.onInitializedfires once Google Mobile Ads is ready. Load the first ads there.
Audience settings such as
setAgeRestriction and
setMaxAdContentRating go before
requestConsent().
// Ads.h
#include "ScriptBase.h"
#include "AdMob.h"
class Ads : public doriax::ScriptBase {
public:
Ads(doriax::Scene* scene, doriax::Entity entity);
~Ads();
void showBreak();
void onConsentUpdated(int errorCode, std::string message);
void onInitialized();
void onAdDismissed(doriax::AdMobFormat format);
};
// Ads.cpp
#include "Ads.h"
using namespace doriax;
static const char* interstitialId = "ca-app-pub-xxxxxxxxxxxxxxxx/xxxxxxxxxx";
Ads::Ads(Scene* scene, Entity entity): ScriptBase(scene, entity) {
REGISTER_EVENT(AdMob::onConsentUpdated, onConsentUpdated);
REGISTER_EVENT(AdMob::onInitialized, onInitialized);
REGISTER_EVENT(AdMob::onAdDismissed, onAdDismissed);
AdMob::requestConsent();
}
Ads::~Ads() {
UNREGISTER_EVENT(AdMob::onConsentUpdated, onConsentUpdated);
UNREGISTER_EVENT(AdMob::onInitialized, onInitialized);
UNREGISTER_EVENT(AdMob::onAdDismissed, onAdDismissed);
}
void Ads::onConsentUpdated(int errorCode, std::string message) {
if (errorCode != 0) {
Log::warn("Consent: %s", message.c_str());
}
if (AdMob::canRequestAds() && !AdMob::isInitialized()) {
AdMob::initialize();
}
}
void Ads::onInitialized() {
AdMob::loadInterstitialAd(interstitialId);
}
// At a natural break, like the end of a level
void Ads::showBreak() {
if (AdMob::isInterstitialAdLoaded()) {
AdMob::showInterstitialAd();
}
}
// Each ad shows once, so load the next one
void Ads::onAdDismissed(AdMobFormat format) {
if (format == AdMobFormat::INTERSTITIAL) {
AdMob::loadInterstitialAd(interstitialId);
}
}
local Ads = {}
local INTERSTITIAL_ID = "ca-app-pub-xxxxxxxxxxxxxxxx/xxxxxxxxxx"
function Ads:init()
RegisterEvent(self, AdMob.onConsentUpdated, "onConsentUpdated")
RegisterEvent(self, AdMob.onInitialized, "onInitialized")
RegisterEvent(self, AdMob.onAdDismissed, "onAdDismissed")
AdMob.requestConsent()
end
function Ads:onConsentUpdated(errorCode, message)
if errorCode ~= 0 then
Log.warn("Consent: " .. message)
end
if AdMob.canRequestAds() and not AdMob.isInitialized() then
AdMob.initialize()
end
end
function Ads:onInitialized()
AdMob.loadInterstitialAd(INTERSTITIAL_ID)
end
-- At a natural break, like the end of a level
function Ads:showBreak()
if AdMob.isInterstitialAdLoaded() then
AdMob.showInterstitialAd()
end
end
-- Each ad shows once, so load the next one
function Ads:onAdDismissed(format)
if format == AdMobFormat.INTERSTITIAL then
AdMob.loadInterstitialAd(INTERSTITIAL_ID)
end
end
return Ads
When isPrivacyOptionsRequired()
is true, Google requires a way back to the consent form, such as a Privacy button in
the settings menu that calls showPrivacyOptionsForm().
Full-screen ads¶
Interstitial, rewarded, rewarded interstitial and app open ads work the same way:
| Step | Call | Result |
|---|---|---|
| Load | loadInterstitialAd(adUnitId), loadRewardedAd, loadRewardedInterstitialAd, loadAppOpenAd |
onAdLoaded(format) or onAdFailedToLoad(format, errorCode, message) |
| Check | isInterstitialAdLoaded(), ... |
true between onAdLoaded and the show |
| Show | showInterstitialAd(), ... |
onAdShown, onAdImpression, then onAdDismissed; or onAdFailedToShow |
- Loading a format again replaces its ad, and only the latest load reports.
- An ad shows once:
is...Loaded()turns false when it is shown, so load the next one, usually inonAdDismissed. - Showing an ad that is not loaded reports
onAdFailedToShowwithAdMob::errorNotLoaded. - A failed load reports Google's error code. Retry after a delay rather than at once: Google advises against reloading in a loop.
Rewarded and rewarded interstitial ads grant their reward through
onUserEarnedReward(format, type, amount), with the type and amount set on the ad unit.
Grant it there, not in onAdDismissed: a player who closes the ad early earns nothing.
For rewards checked by your server, call
setServerSideVerificationOptions
before showing.
App open ads cover the app at launch or when it returns from the background. Google
stops serving one four hours after it loaded, so isAppOpenAdLoaded() turns false by
then, and the ad needs loading again.
Engine::onResume also follows every ad
Since full-screen ads pause the game, onResume fires after
each of them, and after anything else that briefly covers the app, like Google Play's
purchase screen. A game that shows an app open ad from onResume needs a flag, set
before showing its other ads, that skips those resumes.
Banners¶
loadBannerAd(adUnitId, size, position)
places a banner over the game, at the bottom by default. It shows as soon as it loads,
replacing any banner already there.
ADAPTIVE, the default size, takes the screen width when it loads and lets Google pick the height. Load it again after the screen rotates.hideBannerAd()andshowBannerAd()toggle it,setBannerAdPosition()moves it, andremoveBannerAd()destroys it.getBannerAdWidth()andgetBannerAdHeight()give its size in screen pixels once loaded, to keep game UI clear of it. On Android the banner also stays out of display cutouts and visible system bars.- Banners refresh at the rate set on their ad unit. A refresh that fails reports
onAdFailedToLoad(BANNER, ...), but the last ad keeps showing.
Audience and privacy¶
| Call | Use |
|---|---|
setAgeRestriction(CHILD) |
The game is for children. Requests get Google's child treatment, and requestConsent() asks as for a user under the age of consent |
setAgeRestriction(TEEN) |
The game is for teens. Requests get Google's teen treatment |
setMaxAdContentRating(rating) |
Highest content rating of ads: GENERAL, PARENTAL_GUIDANCE, TEEN or MATURE_AUDIENCE |
setPersonalization(DISABLED) |
Only non-personalized ads |
These apply to the requests made after them. See Request settings.
Testing¶
Clicking live ads from your own devices breaks AdMob's policies, so use test ads while developing:
- Use Google's demo ad units, listed for Android and iOS, or
- register your devices with
setTestDeviceIds. Google Mobile Ads prints the device's hashed ID to the device log (Logcat or the Xcode console) on the first request.
On a test device, setConsentDebugGeography(AdMobDebugGeography::EEA) makes the consent
form appear as it would in Europe, and resetConsent() forgets the last answer.
openAdInspector() opens Google's Ad Inspector on a test device, to check each ad unit's
requests and mediation results.
Revenue and volume¶
onAdPaid(format, valueMicros, currencyCode, precision) reports what each ad earned, in
millionths of the currency, for your own analytics. setAppVolume(volume) and
setAppMuted(muted) tell Google how loud video ads should play relative to the game, so
they match a game whose volume is turned down.
In-app purchases¶
InAppPurchase sells one-time products and subscriptions through Google Play Billing on
Android and StoreKit 2 on iOS, with one API: products are queried, bought and settled the
same way on both. The App Store differences are listed at the end.
Setup¶
- Tick Google Play Billing in Project Settings → Platforms → Android. Exports raise Min SDK to 23 for it.
- In the Play Console, create the products: one-time products (consumable or not) and subscriptions with their base plans and offers.
- Upload a build to a testing track (internal testing is enough) with the same package name, and add your Google accounts as license testers so their purchases are not charged. Google Play only sells the products of an app it knows.
- Tick App Store Purchases in Project Settings → Platforms → iOS. Building it needs Xcode 16.3 or later.
- In App Store Connect, create the in-app purchases (consumable, non-consumable or non-renewing subscription) and the auto-renewable subscriptions, which belong to a subscription group.
- Test with a Sandbox Apple Account, or with a StoreKit configuration file selected in the Xcode scheme, which works before anything is set up in App Store Connect.
On every other platform, and in builds without the setting, the calls report
BillingResponse::BILLING_UNAVAILABLE.
The purchase flow¶
initialize()connects to the store.onInitialized(response, message)reportsOKwhen it is ready; calls made before that fail withSERVICE_DISCONNECTED. On Android, a lost connection (onDisconnected) comes back by itself on the next call.queryProducts(ids, type)fetches the localized names and prices. AfteronProductsQueried, read them withgetProducts()orgetProduct(id)to fill the store screen.purchase(productId)opens the store's purchase screen for a queried product. The result isonPurchaseUpdated(purchase), oronPurchaseFailed(productId, response, message)when the player cancels (USER_CANCELED) or something goes wrong.- In
onPurchaseUpdated, grant the product only whenpurchase.stateisPURCHASED. APENDINGpurchase waits for something outside the app, like a cash payment at a store or a parent approving it with Ask to Buy, and the event fires again when it completes. - Settle the purchase:
consumePurchase(token)for consumables, which can then be bought again, andacknowledgePurchase(token)for everything else. Google Play refunds a purchase left unsettled for three days, and the App Store delivers it again at every launch.
Grant a consumable before consuming it, once per token
The same purchase can be reported more than once: by the purchase screen, by a query,
again at the next launch. Once consumed, it is never reported again. So grant and save
a consumable before consuming it, and remember its purchaseToken so that a second
report does not grant it twice.
Purchases also complete outside the purchase screen: a pending payment clears, a promo
code is redeemed, the player buys on another device. Call
queryPurchases(type) for each
product type at startup and on Engine::onResume. It reports every owned purchase through
onPurchaseUpdated (with restored set), then onPurchasesQueried, and drops refunded
and expired ones from getPurchases() and
isPurchased(productId).
Give the store screen a Restore Purchases button too, which calls
restorePurchases(): it queries
both types, and on iOS first syncs the player's App Store account, which may ask them to
sign in. Apple's review guidelines ask for a way to restore purchases that can be
restored.
// Store.h (declarations)
void buyCoins();
void restore();
void onStoreReady(doriax::BillingResponse response, std::string message);
void onResume();
void onPurchaseUpdated(doriax::PurchaseDetails purchase);
void onPurchaseFailed(std::string productId, doriax::BillingResponse response, std::string message);
// Store.cpp
static const std::string coinsId = "coins_100"; // consumable
static const std::string noAdsId = "remove_ads"; // bought once
Store::Store(Scene* scene, Entity entity): ScriptBase(scene, entity) {
REGISTER_EVENT(InAppPurchase::onInitialized, onStoreReady);
REGISTER_EVENT(InAppPurchase::onPurchaseUpdated, onPurchaseUpdated);
REGISTER_EVENT(InAppPurchase::onPurchaseFailed, onPurchaseFailed);
REGISTER_ENGINE_EVENT(onResume);
InAppPurchase::initialize();
}
void Store::onStoreReady(BillingResponse response, std::string message) {
if (response != BillingResponse::OK) {
Log::warn("Store unavailable: %s", message.c_str());
return;
}
InAppPurchase::queryProducts({coinsId, noAdsId}, ProductType::INAPP);
InAppPurchase::queryPurchases(ProductType::INAPP);
}
void Store::onResume() {
if (InAppPurchase::isReady()) {
InAppPurchase::queryPurchases(ProductType::INAPP);
}
}
// From a Buy button
void Store::buyCoins() {
InAppPurchase::purchase(coinsId);
}
// From a Restore Purchases button
void Store::restore() {
InAppPurchase::restorePurchases();
}
void Store::onPurchaseUpdated(PurchaseDetails purchase) {
if (purchase.state != PurchaseState::PURCHASED) return;
if (purchase.productId == coinsId) {
// Grant and save once per token, then consume
const std::string granted = "granted_" + purchase.purchaseToken;
if (!UserSettings::getBoolForKey(granted.c_str(), false)) {
UserSettings::setIntegerForKey("coins", UserSettings::getIntegerForKey("coins", 0) + 100);
UserSettings::setBoolForKey(granted.c_str(), true);
}
InAppPurchase::consumePurchase(purchase.purchaseToken);
} else if (!purchase.acknowledged) {
InAppPurchase::acknowledgePurchase(purchase.purchaseToken);
}
// remove_ads is checked with InAppPurchase::isPurchased(noAdsId)
}
void Store::onPurchaseFailed(std::string productId, BillingResponse response, std::string message) {
if (response != BillingResponse::USER_CANCELED) {
Log::warn("Purchase failed: %s", message.c_str());
}
}
local Store = {}
local COINS = "coins_100" -- consumable
local NO_ADS = "remove_ads" -- bought once
function Store:init()
RegisterEvent(self, InAppPurchase.onInitialized, "onStoreReady")
RegisterEvent(self, InAppPurchase.onPurchaseUpdated, "onPurchaseUpdated")
RegisterEvent(self, InAppPurchase.onPurchaseFailed, "onPurchaseFailed")
RegisterEngineEvent(self, "onResume")
InAppPurchase.initialize()
end
function Store:onStoreReady(response, message)
if response ~= BillingResponse.OK then
Log.warn("Store unavailable: " .. message)
return
end
InAppPurchase.queryProducts({COINS, NO_ADS}, ProductType.INAPP)
InAppPurchase.queryPurchases(ProductType.INAPP)
end
function Store:onResume()
if InAppPurchase.isReady() then
InAppPurchase.queryPurchases(ProductType.INAPP)
end
end
-- From a Buy button
function Store:buyCoins()
InAppPurchase.purchase(COINS)
end
-- From a Restore Purchases button
function Store:restore()
InAppPurchase.restorePurchases()
end
function Store:onPurchaseUpdated(purchase)
if purchase.state ~= PurchaseState.PURCHASED then
return
end
if purchase.productId == COINS then
-- Grant and save once per token, then consume
local granted = "granted_" .. purchase.purchaseToken
if not UserSettings.getBoolForKey(granted, false) then
UserSettings.setIntegerForKey("coins", UserSettings.getIntegerForKey("coins", 0) + 100)
UserSettings.setBoolForKey(granted, true)
end
InAppPurchase.consumePurchase(purchase.purchaseToken)
elseif not purchase.acknowledged then
InAppPurchase.acknowledgePurchase(purchase.purchaseToken)
end
-- remove_ads is checked with InAppPurchase.isPurchased(NO_ADS)
end
function Store:onPurchaseFailed(productId, response, message)
if response ~= BillingResponse.USER_CANCELED then
Log.warn("Purchase failed: " .. message)
end
end
return Store
A consumable left unconsumed, because the app closed before the consume finished, comes
back through queryPurchases or at the next launch; its saved token keeps it from being
granted twice, and it is consumed then.
Subscriptions¶
Query subscriptions with ProductType::SUBS, both with queryProducts and with
queryPurchases. On Google Play, each product lists its base plans and offers in
offers, each with an offerToken
and its pricingPhases, such as a
free week followed by a monthly price. purchase(productId, offerToken) buys that plan;
without a token it buys the first base plan. An App Store subscription has one plan, so
offers holds a single entry, with no token, whose first phase is the introductory offer
when the player is eligible for it.
- Acknowledge a subscription like any non-consumable.
isPurchased(productId)is true while it is active. - A subscription on hold for a payment problem arrives with
suspendedset, andisPurchasedturns false.showInAppMessages()lets Google Play prompt the player to fix the payment, which iOS does by itself, andopenSubscriptionManagement(productId)opens the store's subscription management. changeSubscription(productId, offerToken, oldPurchaseToken, mode)upgrades or downgrades an owned subscription. On Google Play, theSubscriptionReplacementModedecides how the time left on the old plan is billed. On the App Store, it buys the new product, and the App Store replaces the subscription of the same group.
Security¶
Purchase data comes from the player's device, which they can tamper with. For anything
of value, send the purchase to your server and verify it there before granting: the
purchaseToken with the Google Play Developer API, or on iOS the signature, which is
the transaction's JWS, with Apple's App Store Server Library or API.
setObfuscatedAccountId attaches an id of your own user account to the next purchases,
which helps the store detect fraud and helps your server match a purchase to an account.
Use a hash on Google Play; the App Store takes it only as a UUID.
App Store differences¶
| Google Play | App Store | |
|---|---|---|
purchaseToken |
Google Play's purchase token | The original transaction id, which renewals keep |
orderId |
The order id | The transaction id |
originalJson, signature |
The signed purchase data and its signature | The transaction JSON and its JWS |
| Offers | Base plans and offers, chosen with an offerToken |
One plan per product. offerToken is ignored, and the introductory offer applies by itself |
| Unsettled purchases | Refunded after three days | Delivered again at every launch |
changeSubscription |
Applies the replacement mode | Buys the new product; mode is ignored |
setObfuscatedAccountId |
Any id, ideally a hash | A UUID, kept as the transaction's appAccountToken. Anything else is left out with a warning, and the profile id is not used |
| Pending purchases | Payments made outside the app | Ask to Buy: reported as PENDING with no purchaseToken, then as a new purchase once approved |
| Refunds and expirations | Gone after the next queryPurchases |
Gone after the next queryPurchases. Those StoreKit announces while the game runs also arrive through onPurchaseUpdated, with state UNSPECIFIED |
showInAppMessages() |
Shows Google Play's messages | Does nothing: iOS shows them by itself |
openSubscriptionManagement() |
Opens Google Play's subscription center | Shows Apple's subscription sheet over the game, then queries the subscriptions again |
| Non-renewing subscriptions | — | INAPP products that the store never expires: work out the end from purchaseTime |
Web portals¶
Web game portals pay through ads and expect games to report what the player is doing, so
the portal can choose the moment for an ad. WebPortal gives one API for five portals:
| Portal | Game Portal value | Notes |
|---|---|---|
| CrazyGames | CrazyGames | SDK v3 |
| Poki | Poki | SDK v2 |
| GameDistribution | GameDistribution | Needs the Portal Game ID from the GameDistribution dashboard |
| Yandex Games | Yandex Games | The SDK only starts inside Yandex Games |
| YouTube Playables | YouTube Playables | Adds cloud saves and a platform mute |
Each export builds in one portal, chosen with Game Portal in
Project Settings → Platforms → Web, so a game sent
to several portals needs one export each. WebPortal::getPortal() tells which one a
build has.
Using the portal¶
- Call
initialize()once, at startup. It loads the portal's SDK, andonInitialized(environment)reports where the game runs. Calls made afterinitialize()wait for the SDK; calls made before it are dropped, and ads and saves report an error. - Call
loadingStop()once the game can be played. Yandex Games and YouTube Playables require it. Bracket later loading screens withloadingStart()andloadingStop(). - Call
gameplayStart()when play starts or resumes, andgameplayStop()at every break: menus, pauses, the end of a level. - Request ads with
requestAd():MIDGAMEat natural breaks andREWARDEDwhen the player chooses to watch one. The result isonAdStartedthenonAdFinished, oronAdError(type, code, message). - Call
happytime()at happy moments, like a level completed.
// Portal.h (declarations)
void startLevel();
void levelComplete();
void watchAdForCoins();
void onAdFinished(doriax::WebPortalAdType type);
void onAdError(doriax::WebPortalAdType type, std::string code, std::string message);
// Portal.cpp
Portal::Portal(Scene* scene, Entity entity): ScriptBase(scene, entity) {
REGISTER_EVENT(WebPortal::onAdFinished, onAdFinished);
REGISTER_EVENT(WebPortal::onAdError, onAdError);
WebPortal::initialize();
WebPortal::loadingStop(); // waits for the SDK
}
void Portal::startLevel() {
WebPortal::gameplayStart();
}
void Portal::levelComplete() {
WebPortal::happytime();
WebPortal::gameplayStop();
WebPortal::requestAd(WebPortalAdType::MIDGAME); // the portal may show none
}
void Portal::watchAdForCoins() {
WebPortal::requestAd(WebPortalAdType::REWARDED);
}
void Portal::onAdFinished(WebPortalAdType type) {
if (type == WebPortalAdType::REWARDED) {
// add the coins
}
}
void Portal::onAdError(WebPortalAdType type, std::string code, std::string message) {
if (type == WebPortalAdType::REWARDED) {
// tell the player no ad could be shown
}
}
local Portal = {}
function Portal:init()
RegisterEvent(self, WebPortal.onAdFinished, "onAdFinished")
RegisterEvent(self, WebPortal.onAdError, "onAdError")
WebPortal.initialize()
WebPortal.loadingStop() -- waits for the SDK
end
function Portal:startLevel()
WebPortal.gameplayStart()
end
function Portal:levelComplete()
WebPortal.happytime()
WebPortal.gameplayStop()
WebPortal.requestAd(WebPortalAdType.MIDGAME) -- the portal may show none
end
function Portal:watchAdForCoins()
WebPortal.requestAd(WebPortalAdType.REWARDED)
end
function Portal:onAdFinished(adType)
if adType == WebPortalAdType.REWARDED then
-- add the coins
end
end
function Portal:onAdError(adType, code, message)
if adType == WebPortalAdType.REWARDED then
-- tell the player no ad could be shown
end
end
return Portal
Portals decide when a midgame ad really plays, so a request often shows none and reports
onAdError with the code unfilled. The codes are:
| Code | Meaning |
|---|---|
unfilled |
No ad played: the portal had none, chose not to show one now, or another ad is playing |
unavailable |
No portal SDK: a build without a portal, initialize() not called, or the portal refused to start here |
other |
The ad started but failed, or a rewarded ad was closed before its reward |
| A CrazyGames code | CrazyGames passes its own codes, like adCooldown |
A rewarded ad grants its reward only through onAdFinished.
Environments¶
onInitialized and getEnvironment() report a
WebPortalEnvironment:
| Value | Meaning |
|---|---|
UNINITIALIZED |
initialize() has not finished yet |
DISABLED |
No portal in this build, its SDK failed to load, or the portal does not run on this host (CrazyGames outside its site, Yandex Games outside its frame, YouTube outside Playables). Calls do nothing, and ads report unavailable |
LOCAL |
localhost, 127.0.0.1 or [::1], where most portals show test ads |
PORTAL |
Any other host, normally the portal's |
isAvailable() is true for LOCAL and PORTAL.
Pauses and mute¶
The engine pauses while one of its ads plays. GameDistribution,
Yandex Games and YouTube Playables also pause the game themselves: for an ad they start
on their own, a purchase window, or the player switching away. The engine stays paused
until both the ad and the portal let it go, and Engine::onResume follows.
YouTube Playables can mute a game from its own interface. The engine follows it: audio stays silent while YouTube has it off, whatever the game's global volume is.
Cloud saves¶
YouTube Playables saves progress in the player's account. Call loadData() at startup
and read the save in onDataLoaded(data), which is empty when nothing was saved yet.
saveData(data) stores one string, like a JSON document, and reports only failures,
through onDataSaveFailed. YouTube refuses saves made before a loadData(), and its
rules require progress to be saved this way.
Other portals have no cloud save, so loadData() reports onDataLoadFailed. Save there
with UserSettings, which the web stores in the
browser.
What each portal supports¶
| Call or event | CrazyGames | Poki | GameDistribution | Yandex Games | YouTube Playables |
|---|---|---|---|---|---|
requestAd(MIDGAME) |
Midgame ad | Commercial break | Interstitial | Fullscreen ad | Interstitial |
requestAd(REWARDED) |
Yes | Yes | Yes | Yes | Yes |
onAdStarted |
Yes | Yes | Yes | Yes | No: YouTube pauses the game instead |
gameplayStart / gameplayStop |
Yes | Yes | No | Yes | No |
loadingStart |
Yes | Yes | No | No | No |
loadingStop |
Yes | Yes | No | Required | Required |
happytime |
Yes | Yes | No | No | No |
loadData / saveData |
No | No | No | No | Yes |
| Pauses the game itself | No | No | Yes | Yes | Yes |
A call a portal does not support does nothing.
Testing on your machine¶
Serve the exported folder from localhost (see
Export Window → Web mode) and the portal SDKs act as
follows:
- CrazyGames shows demo ads on
localhost. On another host outside CrazyGames, add?useLocalSdk=trueto the URL for the same. - Poki runs in debug mode on
localhost, with test ads. - GameDistribution needs the Portal Game ID even locally. For test ads, set the
gd_debug_exandgd_tagkeys of the page'slocalStoragetotrue. - Yandex Games loads its SDK from
/sdk.js, which Yandex's hosting serves; on other hosts the portal isDISABLED. Yandex's@yandex-games/sdk-dev-proxyserves a mock SDK with placeholder ads (run it with--dev-mode=true). - YouTube Playables runs only inside YouTube, and is
DISABLEDanywhere else. The export adds YouTube's SDK<script>to the page head, which YouTube requires to load before the game. Test it with YouTube's Playables test suite.
Migrating from the System methods¶
Earlier versions had AdMob and CrazyGames methods on System.
They were replaced by the classes on this page:
| Before | Now |
|---|---|
System::initializeAdMob(childDirected, underAge) |
AdMob::setAgeRestriction(AdMobAgeRestriction::CHILD) for a children's game, then AdMob::requestConsent() and AdMob::initialize(), as in Consent, then initialize |
System::setMaxAdContentRating(AdMobRating::General) |
AdMob::setMaxAdContentRating(AdMobRating::GENERAL). The values are upper case now, with UNSPECIFIED added |
System::loadInterstitialAd(id), isInterstitialAdLoaded(), showInterstitialAd() |
AdMob::loadInterstitialAd(id), AdMob::isInterstitialAdLoaded(), AdMob::showInterstitialAd() |
System::initializeCrazyGamesSDK() |
Game Portal set to CrazyGames, then WebPortal::initialize() |
System::showCrazyGamesAd("midgame"), ("rewarded") |
WebPortal::requestAd(WebPortalAdType::MIDGAME), (WebPortalAdType::REWARDED) |
System::happytimeCrazyGames() |
WebPortal::happytime() |
System::gameplayStartCrazyGames(), gameplayStopCrazyGames() |
WebPortal::gameplayStart(), WebPortal::gameplayStop() |
System::loadingStartCrazyGames(), loadingStopCrazyGames() |
WebPortal::loadingStart(), WebPortal::loadingStop() |
Lua scripts make the same change: System.showInterstitialAd() becomes
AdMob.showInterstitialAd(). Ads now also need Google AdMob or Game Portal in
the project settings, since exports only include the SDKs that are enabled.