Chương 3: Quảng cáo trong Game với App.Ads

Quản lý toàn bộ vòng đời hiển thị quảng cáo Banner, Interstitial, Rewarded Video và App-Open thông qua giao diện thống nhất IAdsService được truy cập nhanh qua lối tắt App.Ads. Tự động hóa điều tiết tần suất hiển thị (capping), xử lý gói Xóa quảng cáo (Remove Ads), ngắt/bật âm thanh đồng bộ và cam kết hợp đồng callback tin cậy.

1. Mục đích & Triết lý vận hành

Trong quá trình phát triển game di động, việc gọi trực tiếp các API của mạng quảng cáo (AppLovin MAX, SayKit, Google AdMob, v.v.) vào sâu trong mã nguồn gameplay dẫn đến các vấn đề nghiêm trọng: mã nguồn bị phụ thuộc chặt vào một bên thứ ba, khó kiểm soát thời gian chờ (cooldown), dễ gây xung đột âm thanh, hoặc thậm chí gây treo mạch logic khi quảng cáo không tải được.

MGW SDK giải quyết triệt để vấn đề này bằng triết lý kiến trúc phân tách ranh giới rõ ràng:

Triết lý thiết kế cốt lõi: Game decides WHEN (placement), Core decides IF (policy)

Phía Game: Chỉ cần thông báo cho SDK biết thời điểm và vị trí trong trải nghiệm người chơi (ví dụ: vừa hoàn thành level, mở rương nhận thưởng, hay bấm nút quay về sảnh chính).
Phía SDK Core: Đóng vai trò bộ lọc trung tâm kiểm tra điều kiện: Người chơi đã mua gói Remove Ads chưa? Đã đủ thời gian giãn cách tối thiểu từ lần xem quảng cáo trước chưa? Quảng cáo đã tải về bộ đệm thành công chưa? Nếu tất cả thỏa mãn, quảng cáo mới hiển thị; ngược lại, SDK kích hoạt callback tiếp tục ngay lập tức để không làm gián đoạn trải nghiệm.

2. Bảng tra cứu API IAdsService đầy đủ

Mọi tương tác quảng cáo đều thông qua interface TapBrain.Core.Services.IAdsService, lấy từ thuộc tính App.Ads:

Thành viên (Member) Kiểu & Chữ ký Mô tả chi tiết & Hợp đồng Callback
IsInitialized bool (get) true khi SDK quảng cáo nền tảng (MAX hoặc SayKit) đã hoàn tất khởi tạo.
IsInterstitialReady bool (get) true khi video xen kẽ (Interstitial) đã được tải sẵn vào bộ đệm và sẵn sàng hiển thị.
IsRewardedReady bool (get) true khi video nhận thưởng (Rewarded) đã sẵn sàng phục vụ người chơi.
Initialized event Action Bắn ra một lần duy nhất khi hệ thống quảng cáo khởi tạo xong. Không phát lại cho người đăng ký muộn: hãy kiểm tra IsInitialized trước, nếu còn false mới đăng ký sự kiện.
FullscreenOpened event Action<AdFormat> Bắn ra ngay khi một quảng cáo toàn màn hình bắt đầu mở lên. Nhận định dạng quảng cáo tương ứng.
FullscreenClosed event Action<AdFormat> Bắn ra khi quảng cáo toàn màn hình đóng lại (hoặc người dùng bấm bỏ qua). Với MAX, FullscreenClosed bắn ngay lúc quảng cáo đóng, trước kết quả của ShowRewarded (xem mục 5). Đừng cấp thưởng hay chuyển cảnh trong sự kiện này. Với SayKit, cặp Opened/Closed còn bắn cả khi không có quảng cáo nào hiển thị (xem mục 6), vì vậy hãy dùng giá trị trả về của TryShowInterstitial để biết quảng cáo có chiếu thật không.
ShowBanner() void MAX: hiển thị banner ở cạnh dưới màn hình (BottomCenter). Lệnh bị bỏ qua nếu đã mua Remove Ads, thiếu bannerAdUnit, hoặc gọi trước khi quảng cáo khởi tạo xong (IsInitialized == false). Banner sẽ tự hiện sau khi khởi tạo nếu CoreConfig.autoShowBanner bật (mặc định true). SayKit: vị trí và việc ẩn banner do SayKit quyết định; Remove Ads có tác dụng nhờ SayKit.enablePremium().
HideBanner() void Ẩn banner đang hiển thị khỏi màn hình.
TryShowInterstitial() bool TryShowInterstitial(string placement, Action onClosed = null) Thử hiển thị quảng cáo xen kẽ. Trả về false nếu bị chặn bởi Policy, chưa sẵn sàng hoặc đang có fullscreen khác.
Hợp đồng: Tham số onClosed luôn được gọi đúng 1 lần. Khi bị bỏ qua, callback được kích hoạt đồng bộ ngay trước khi hàm trả về false.
ShowRewarded() void ShowRewarded(string placement, Action<RewardedResult> onResult) Hiển thị video có thưởng. Tham số onResult luôn được gọi đúng 1 lần.
Nếu chưa sẵn sàng, bắn RewardedResult.NotReady đồng bộ. Chỉ cấp thưởng khi kết quả là RewardedResult.Rewarded. Các kết quả Rewarded/Skipped được trả bất đồng bộ sau khi quảng cáo đóng.
TryShowAppOpen() bool TryShowAppOpen() Thử hiển thị quảng cáo App-Open. Trả về true nếu quảng cáo được mở. Trả về false khi chưa sẵn sàng (đồng thời bắt đầu tải), đã mua Remove Ads, đang có fullscreen khác, app_open_enabled đang tắt, hoặc chưa điền CoreConfig.appOpenAdUnit (mặc định trống = tắt). Với MAX, SDK còn tự gọi hàm này khi game quay lại từ background, nên thường game không cần gọi. Trên SayKit luôn trả về false.
ShowMediationDebugger() void ShowMediationDebugger() Mở màn hình Mediation Debugger của AppLovin MAX để kiểm tra trạng thái tích hợp của các ad network đối tác. Với MAX chỉ có tác dụng khi IsInitialized == true; với NullAdsService chỉ ghi một cảnh báo.

Các Enum định dạng và kết quả quảng cáo

Trong namespace TapBrain.Core.Services, hai enum sau được dùng xuyên suốt:

namespace TapBrain.Core.Services
{
    public enum AdFormat
    {
        Banner,        // Banner strip
        Interstitial,  // Fullscreen interstitial
        Rewarded,      // Opt-in rewarded video
        AppOpen        // App-open ad shown when returning from background
    }

    public enum RewardedResult
    {
        Rewarded,  // Watched far enough: grant the reward ONLY here
        Skipped,   // Closed early (SayKit also reports a failed show as Skipped)
        NotReady,  // Not loaded yet, or another fullscreen ad is showing (MAX)
        Failed     // Display error from the ad network (MAX only; SayKit never returns Failed)
    }
}

3. Cơ chế điều tiết tần suất AdsPolicy & Remote Config

Đối với Provider MGW (sử dụng AppLovin MAX), việc điều tiết tần suất hiển thị Interstitial được thực hiện tự động phía client bởi class AdsPolicy. Các thông số kiểm soát được lấy từ dịch vụ Remote Config (hoặc giá trị mặc định nếu thiết bị offline):

Remote Config Key Hằng số SDK Mặc định Ý nghĩa & Hành vi
inter_first_delay_sec RemoteConfigKeys.InterstitialFirstDelaySeconds 60 (giây) Khoảng thời gian tối thiểu kể từ lúc SDK khởi động (AdsPolicy được tạo trong quá trình boot, tính theo Time.realtimeSinceStartup) trước khi cho phép Interstitial đầu tiên hiển thị. Chỉ áp dụng cho Interstitial, không áp dụng cho Rewarded.
inter_interval_sec RemoteConfigKeys.InterstitialIntervalSeconds 30 (giây) Khoảng thời gian cách ly tối thiểu kể từ lần đóng quảng cáo toàn màn hình gần nhất (Interstitial, Rewarded hoặc App-Open) trước khi cho phép Interstitial tiếp theo.
app_open_enabled RemoteConfigKeys.AppOpenEnabled false Cờ cho phép hiển thị quảng cáo App-Open khi người dùng trở lại game từ trạng thái Pause/Background.
Cơ chế quan trọng: Rewarded Ads cũng reset bộ đếm thời gian cách ly!

Mỗi khi một quảng cáo toàn màn hình đóng lại (kể cả Rewarded lẫn Interstitial), SDK đều gọi AdsPolicy.MarkFullscreenClosed() để cập nhật mốc thời gian. Điều này ngăn chặn tình huống tồi tệ nhất trong trải nghiệm người dùng: người chơi vừa chủ động xem xong một video có thưởng thì ngay màn hình tiếp theo lại bị ép xem thêm một video xen kẽ.

4. Cơ chế Xóa quảng cáo (Remove Ads)

Khi người chơi thực hiện mua gói Xóa quảng cáo (thông qua mã sản phẩm cấu hình tại CoreConfig.removeAdsProductId), SDK Core sẽ xử lý tự động:

  • Ghi nhận vĩnh viễn vào bộ lưu trữ cục bộ với khóa core.remove_ads. Game đọc trạng thái bằng App.Iap.IsOwned(removeAdsProductId) (AdsPolicy và khóa lưu trữ là nội bộ của SDK, game không gọi trực tiếp được).
  • Kích hoạt sự kiện App.Iap.RemoveAdsGranted.
  • Tự động ẩn Banner hiện tại ngay lập tức nếu đang hiển thị (MAX ẩn banner; SayKit gọi SayKit.enablePremium(), và gọi lại ở mỗi lần khởi động nếu đã mua).
  • Chặn toàn bộ các lệnh gọi hiển thị Banner, Interstitial và App-Open. Mọi lệnh gọi TryShowInterstitial() sau đó sẽ lập tức gọi onClosed và trả về false (với MAX, log ghi lý do remove_ads).
  • Quảng cáo Rewarded VẪN HOẠT ĐỘNG: Người chơi đã mua gói Remove Ads vẫn hoàn toàn có quyền chủ động bấm xem Rewarded Video để nhận vàng, mạng chơi hoặc nhân đôi phần thưởng.

Để tìm hiểu chi tiết cách thiết lập mã sản phẩm trong cấu hình và xử lý phục hồi giao dịch (Restore Purchases), hãy xem Chương 4: In-App Purchase (App.Iap).

5. Quy ước hợp đồng thời gian chạy (Runtime Contracts)

Để đảm bảo luồng điều khiển trong game không bao giờ bị đứng hoặc rò rỉ bộ nhớ, người lập trình cần ghi nhớ các cam kết thời gian chạy sau:

Hợp đồng của TryShowInterstitial

Phương thức App.Ads.TryShowInterstitial(placement, onClosed) tuân thủ nghiêm ngặt quy tắc:

  • Callback onClosed được đảm bảo kích hoạt chính xác một lần trong mọi tình huống.
  • Nếu quảng cáo bị bỏ qua (do chính sách AdsPolicy chặn, chưa tải xong, đang có fullscreen khác, hoặc người chơi đã mua Remove Ads), callback onClosed được thực thi đồng bộ ngay lập tức trước khi hàm trả về giá trị false.
  • Quy tắc vàng: Luôn đặt logic tiếp tục luồng game (chuyển scene, mở hộp thoại kết quả, resume âm thanh) bên trong callback onClosed, không viết nối tiếp ngay sau lệnh gọi.

Hợp đồng của ShowRewarded

  • Callback onResult được đảm bảo kích hoạt chính xác một lần.
  • Nếu quảng cáo chưa sẵn sàng (!IsRewardedReady) hoặc (MAX) đang hiển thị fullscreen khác, callback được gọi ngay đồng bộ với tham số RewardedResult.NotReady. Chỉ trường hợp này (và Failed khi lỗi hiển thị) có thể đồng bộ.
  • Khi quảng cáo đã chiếu, Rewarded/Skipped được trả bất đồng bộ: với MAX trễ khoảng 0.1 giây sau FullscreenClosed (để cờ nhận thưởng kịp ổn định). Callback có thể chạy khi UI đã bị hủy (đổi scene trong lúc xem quảng cáo), nên hãy xử lý phần thưởng trước rồi mới đụng tới UI và kiểm tra đối tượng còn tồn tại.
  • Với SayKit, IsRewardedReady phản ánh place mặc định (ad_rewarded) và được cache 1 giây, còn ShowRewarded(placement) kiểm tra trực tiếp theo placement bạn truyền.
  • Tuyệt đối chỉ cấp thưởng khi: result == RewardedResult.Rewarded. Tuyệt đối không cấp thưởng khi kết quả là Skipped, NotReady, hoặc Failed.

Tự động ngắt và khôi phục âm thanh

Khi một quảng cáo toàn màn hình chuẩn bị mở (BeginFullscreen), provider MGW (MAX) gọi AudioService.SetPausedByAd(true), tức đặt AudioListener.pause = true (tạm dừng toàn bộ âm thanh, không phải tắt tiếng từng kênh), rồi bắn sự kiện FullscreenOpened. Khi quảng cáo đóng lại (EndFullscreen), SDK đặt lại AudioListener.pause = false và bắn FullscreenClosed. Với MAX, gameplay không cần tự viết code tắt tiếng. Với SayKit, Core không can thiệp vào âm thanh: việc dừng âm thanh do SayKit tự xử lý (trên iOS), nên nếu game cần dừng nhạc riêng trên mọi nền tảng, hãy đăng ký FullscreenOpened/FullscreenClosed.

Quy chuẩn đặt tên Placement

Tham số placement đại diện cho vị trí xuất hiện của quảng cáo trong ngữ cảnh trò chơi. Việc đặt tên vị trí chuẩn xác giúp đội ngũ Vận hành & Dữ liệu (BI) phân tích hiệu quả doanh thu và eCPM từng điểm chạm:

  • Sử dụng định dạng chữ thường nối gạch dưới (snake_case).
  • Tên phải thể hiện rõ ngữ cảnh vị trí, ví dụ: level_end, level_retry, revive, double_reward, home_screen, shop_free_coins.
  • Nên khai báo các placement thành các hằng số const string tập trung trong mã nguồn game thay vì truyền chuỗi rời rạc.
  • Với SayKit, chuỗi placement được truyền thẳng làm place của SayKit (giới hạn tần suất phía server tính theo từng place), nên hãy dùng đúng các place được SayGames cấu hình cho game.

6. So sánh chi tiết giữa MGW (MAX) và SayKit

Khi chuyển đổi Provider giữa MGW và SayKit, mã nguồn gameplay sử dụng App.Ads hoàn toàn không cần thay đổi, nhưng hành vi bên dưới sẽ có những điểm khác biệt đặc trưng:

Đặc điểm Provider MGW (Mặc định) Provider SayKit
Hạ tầng Mediation AppLovin MAX SDK (quản lý ad unit qua CoreConfig). SayKit SDK tích hợp sẵn (quản lý qua SayGames backend).
Điều tiết tần suất (Capping) Client-side thông qua AdsPolicy kết hợp Remote Config. Server-side theo từng place (Core bỏ qua AdsPolicy để tránh bị chặn 2 lần).
Quảng cáo App-Open Hỗ trợ đầy đủ (kích hoạt qua TryShowAppOpen() hoặc khi resume). Không hỗ trợ (TryShowAppOpen() luôn trả về false).
Kiểm tra sẵn sàng (Readiness) Kiểm tra trực tiếp qua hàm sẵn sàng của MAX SDK. Được cache trong 1 giây (ReadyCacheSeconds = 1f) để tránh bị SayKit cảnh báo spam kiểm tra.
Báo cáo kết quả Rewarded Phân biệt rõ: Rewarded, Skipped, NotReady, Failed. SayKit native bridge chỉ trả về bool: Thành công map thành Rewarded; Thất bại hoặc người dùng tắt sớm đều map thành Skipped.
Xử lý Remove Ads Ẩn Banner; cờ core.remove_ads khiến AdsPolicy chặn Interstitial và App-Open. Gọi SayKit.enablePremium() (tắt banner, interstitial, cross-promo; giữ lại rewarded).
Mediation Debugger Mở màn hình MaxSdk.ShowMediationDebugger(). Mở màn hình SayKit.ShowMaxMediationDebug().
Sự kiện FullscreenOpened/Closed Chỉ bắn khi quảng cáo thực sự mở/đóng. Có guard "đang hiển thị" nên không mở chồng quảng cáo. Bắn FullscreenOpened trước lệnh show và FullscreenClosed ngay cả khi không có quảng cáo nào được chiếu (cặp Opened→Closed đồng bộ). Không có guard "đang hiển thị" phía Core.
Âm thanh khi quảng cáo Core đặt AudioListener.pause quanh quảng cáo. Core không can thiệp; SayKit tự xử lý (iOS).
IsRewardedReady / IsInterstitialReady Theo ad unit cấu hình trong CoreConfig. Theo place mặc định (ad_interstitial, ad_rewarded), không theo placement bạn truyền khi show.
Cơ chế Fallback an toàn (NullAdsService) và enableAds:

NullAdsService chỉ được đăng ký khi không có provider nào cung cấp IAdsService (chưa tích hợp provider, hoặc provider bị lỗi lúc cài đặt). Trong chế độ này, TryShowInterstitial lập tức gọi onClosed và trả về false, ShowRewarded gọi ngay với kết quả NotReady, các sự kiện không bao giờ bắn.
Đặt enableAds = false không đăng ký NullAdsService: với MGW, MAX chỉ đơn giản không được khởi tạo (IsInitialized = false) nên hành vi callback giống như trên; với SayKit, cờ enableAds không có tác dụng. Nhờ các hợp đồng này, code game không cần kiểm tra null hay provider.

7. Các dòng Console Log chuẩn cần xác minh

Trong quá trình chạy thử nghiệm, bạn có thể kiểm tra cửa sổ Unity Console với bộ lọc tiền tố [Ads]. Các dòng dưới đây chỉ có với provider MGW (MAX), trừ dòng cuối của NullAdsService; provider SayKit không ghi các log quảng cáo tương ứng. Lưu ý: các dòng mức Info chỉ được in khi CoreConfig.verboseLog bật hoặc trong Development Build; Warning và Error luôn được in.

Dòng Log Console Mức độ Nguyên nhân & Ý nghĩa
[Ads] MAX initialized Info AppLovin MAX SDK đã khởi tạo thành công và bắt đầu tải trước các ad unit.
[Ads] MAX SDK key not set in CoreConfig, ads disabled Warning Trường maxSdkKey trong CoreConfig.asset còn trống hoặc đang giữ giá trị mặc định.
[Ads] Inter skipped at {placement}: {reason} Info Quảng cáo xen kẽ bị bỏ qua tại vị trí {placement}. Lý do {reason} có thể là:
- remove_ads: Người chơi đã mua gói Xóa quảng cáo.
- first_delay: Chưa qua thời gian chờ ban đầu của phiên chơi.
- interval: Chưa đủ thời gian cách ly giữa 2 lần quảng cáo.
- not_ready: Video chưa tải xong về thiết bị.
- showing: Đang có một quảng cáo toàn màn hình khác đang hiển thị.
[Ads] Inter display failed: {code} {message} Warning Quảng cáo xen kẽ gặp lỗi khi cố gắng hiển thị lên màn hình.
[Ads] Rewarded display failed: {code} {message} Warning Video nhận thưởng gặp lỗi khi đang hiển thị.
[Ads] No ads provider compiled in, mediation debugger unavailable Warning Lệnh mở Mediation Debugger được gọi khi đang dùng NullAdsService.

8. Hướng dẫn thực hành & Ví dụ mã nguồn C#

Ví dụ 1: Hiển thị Interstitial khi hoàn thành màn chơi

Kịch bản: Người chơi vừa vượt qua một màn chơi. Game muốn hiển thị quảng cáo xen kẽ trước khi chuyển sang màn chơi tiếp theo. Chú ý cách toàn bộ logic chuyển màn được đặt trong callback onClosed và cờ _transitioning chặn việc gọi lặp. Không cần dùng ShowBlocker: quảng cáo toàn màn hình đã che giao diện, và nếu bị bỏ qua thì onClosed chạy ngay.

using UnityEngine;
using TapBrain.Core;

namespace MyGame.Gameplay
{
    public sealed class LevelCompletionFlow : MonoBehaviour
    {
        private const string PlacementLevelEnd = "level_complete";

        private bool _transitioning;

        // nextSceneBuildIndex is a build index from Build Settings, not a level number.
        public void OnLevelFinished(int nextSceneBuildIndex)
        {
            // Guard: a second call while the ad is on screen must not chain the transition twice.
            if (_transitioning) return;
            _transitioning = true;

            // onClosed fires exactly once: after the ad closes, or synchronously
            // (before this call returns) when the ad is skipped by policy / not ready.
            App.Ads.TryShowInterstitial(PlacementLevelEnd, onClosed: () =>
            {
                _transitioning = false;
                ProceedToNextLevel(nextSceneBuildIndex);
            });
        }

        private void ProceedToNextLevel(int nextSceneBuildIndex)
        {
            Debug.Log($"[Gameplay] Loading scene {nextSceneBuildIndex}");
            App.Scenes.Load(nextSceneBuildIndex);
        }
    }
}

Ví dụ 2: Nút xem Rewarded Video hồi sinh hoặc nhân thưởng

Kịch bản: Nút UI cho phép người chơi xem video để nhận thêm 1 mạng chơi (Revive). Vì IAdsService không có sự kiện "quảng cáo đã tải xong", nút làm mới trạng thái IsRewardedReady mỗi giây (không bao giờ mỗi frame), xử lý phần thưởng trước khi đụng tới UI (callback có thể chạy khi view đã bị hủy), và chỉ trao thưởng khi nhận được RewardedResult.Rewarded:

using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TapBrain.Core;
using TapBrain.Core.Services;

namespace MyGame.UI
{
    public sealed class ReviveRewardButton : MonoBehaviour
    {
        private const string PlacementRevive = "revive_player";
        private const float ReadyPollSeconds = 1f;

        [SerializeField] private Button _watchAdButton;
        [SerializeField] private GameObject _adNotReadyHint;

        private Coroutine _pollRoutine;
        private bool _adShowing;

        private void OnEnable()
        {
            _watchAdButton.onClick.AddListener(OnWatchAdClicked);
            _pollRoutine = StartCoroutine(PollReadiness());
        }

        private void OnDisable()
        {
            _watchAdButton.onClick.RemoveListener(OnWatchAdClicked);
            if (_pollRoutine != null) StopCoroutine(_pollRoutine);
            _pollRoutine = null;
        }

        // There is no "ad loaded" event, so poll slowly (never per frame).
        // Stop condition: stopped in OnDisable.
        private IEnumerator PollReadiness()
        {
            var wait = new WaitForSecondsRealtime(ReadyPollSeconds);
            while (true)
            {
                if (!_adShowing) RefreshButton();
                yield return wait;
            }
        }

        private void RefreshButton()
        {
            bool ready = App.Ads.IsRewardedReady;
            _watchAdButton.interactable = ready;
            if (_adNotReadyHint != null) _adNotReadyHint.SetActive(!ready);
        }

        private void OnWatchAdClicked()
        {
            _adShowing = true;
            _watchAdButton.interactable = false;

            App.Ads.ShowRewarded(PlacementRevive, result =>
            {
                _adShowing = false;

                // Resolve the reward FIRST and keep it independent of this view:
                // the callback can fire after the view was destroyed (scene change during the ad).
                switch (result)
                {
                    case RewardedResult.Rewarded:
                        GrantReviveLife();
                        break;

                    case RewardedResult.Skipped:
                        App.Overlay.Toast("Bạn cần xem hết video để nhận thêm mạng chơi!");
                        break;

                    case RewardedResult.NotReady:
                        App.Overlay.Toast("Quảng cáo chưa sẵn sàng, hãy thử lại sau ít giây.");
                        break;

                    case RewardedResult.Failed:
                        Debug.LogWarning("[Reward] Rewarded ad failed to display");
                        App.Overlay.Toast("Không phát được video lúc này. Vui lòng thử lại sau.");
                        break;
                }

                // Touch UI only if this view still exists (UnityEngine.Object null check).
                if (this != null) RefreshButton();
            });
        }

        private void GrantReviveLife()
        {
            // Call the revive logic of your own game systems here (must not depend on this view).
            App.Overlay.Toast("Hồi sinh thành công! Tiếp tục cuộc chơi.");
        }
    }
}

9. Các lỗi phổ biến & So sánh Đúng vs Sai

Cách làm sai (Dễ gây lỗi / Vi phạm hợp đồng)
// 1. GRANTING THE REWARD OUTSIDE THE CALLBACK
App.Ads.ShowRewarded("free_gold", result => { });
GrantGold(); // BUG: the player gets gold even if the ad never played

// 2. GRANTING THE REWARD ON ANY RESULT
App.Ads.ShowRewarded("free_gold", result => {
    // BUG: result is not checked against Rewarded
    GrantGold();
});

// 3. CONTINUING THE FLOW OUTSIDE onClosed
App.Ads.TryShowInterstitial("level_end");
App.Scenes.Load(2); // BUG: the scene loads while the ad is starting; onClosed (if any) runs later and out of order

// 4. CALLING THE AD EVERY FRAME
void Update() {
    if (gameOver) App.Ads.TryShowInterstitial("over"); // BUG: fires every frame while gameOver is true
}
Cách làm đúng chuẩn SDK
// 1. GRANT ONLY ON RewardedResult.Rewarded
App.Ads.ShowRewarded("free_gold", result => {
    if (result == RewardedResult.Rewarded) {
        GrantGold(); // OK: the player watched the ad far enough
    }
});

// 2. CONTINUE INSIDE onClosed
App.Ads.TryShowInterstitial("level_end", onClosed: () => {
    // OK: runs after the ad closes, or immediately when the ad was skipped
    App.Scenes.Load(2);
});

// 3. TRIGGER FROM A SINGLE EVENT
public void OnGameOver() {
    App.Ads.TryShowInterstitial("game_over", onClosed: ShowResultPopup);
}

10. Checklist kiểm tra hệ thống quảng cáo

Đảm bảo maxSdkKey và các Ad Unit ID của Banner, Interstitial, Rewarded đã được điền cho cả Android và iOS, không còn trống hoặc giữ giá trị REPLACE_ME (thiếu maxSdkKey thì toàn bộ quảng cáo bị tắt). Muốn dùng App-Open, điền thêm appOpenAdUnit và bật app_open_enabled trên Remote Config.
Xác nhận tất cả các vị trí gọi TryShowInterstitial đều đưa code chuyển cảnh hoặc đóng mở popup vào callback onClosed.
Kiểm tra toàn bộ các nút xem video nhận quà, đảm bảo không có vị trí nào cấp thưởng khi người dùng tắt giữa chừng (Skipped).
Thử nghiệm mua gói Remove Ads: Banner và Interstitial phải bị chặn hoàn toàn, trong khi các nút xem Rewarded Video vẫn hoạt động bình thường.