There are few bugs more damaging to player retention than an ad loop. The player watches a rewarded video, closes it, and the game immediately triggers the same ad again. Or worse, the ad closes, but the game logic remains frozen in a paused state.
This isn't just a bad user experience; it violates ad network policies and can get your AdMob or Unity Ads account suspended for invalid traffic generation.
This issue rarely stems from the Ad SDK itself. It is almost always a logic error involving dirty state flags, event subscription leaks, or non-main thread callbacks.
This guide breaks down the root causes of the "infinite ad loop" and provides a robust, thread-safe C# implementation to fix it.
The Root Cause: Why Ads Loop
To fix the loop, you must understand the two architectural flaws that cause it.
1. The Event Subscription Leak
The most common cause of ad looping is delegate accumulation.
In Unity, developers often subscribe to an OnAdClosed event inside the "Show Ad" function. If you use the += operator to subscribe but fail to unsubscribe with -= (or if the unsubscribe happens in a block that isn't reached), every time you show an ad, you add another listener.
The Scenario:
- First Ad: You subscribe
ResumeGame()toOnAdClosed. - Ad Closes:
ResumeGame()runs once. - Second Ad: You subscribe
ResumeGame()again. The event now has two listeners. - Ad Closes:
ResumeGame()runs twice.
If ResumeGame() contains logic that checks for game-over states or triggers level transitions, running it multiple times often re-triggers the condition that shows the ad in the first place.
2. Thread Context Race Conditions
Unity Ads (and many other SDKs) often fire their completion callbacks on a background thread, not the Unity Main Thread.
If your callback tries to modify a boolean flag (e.g., isAdShowing = false) or interact with the UI, and you aren't on the main thread, the change may not register immediately in the current frame's Update loop. This creates a "zombie state" where the game thinks the ad is still open, or conversely, thinks it closed instantly before the content even loaded.
The Solution: A Finite State Machine (FSM) Approach
Do not rely on simple booleans like isAdShowing. They are brittle. Instead, implement a strict State Machine for your Ad Manager. This ensures the system can physically never trigger an ad while another is processing.
We will also implement strict One-Shot Callbacks to prevent subscription leaks.
The Robust Ad Manager Implementation
Below is a production-grade pattern. It uses the IUnityAdsShowListener interface (standard for Unity Ads 4.x+) and handles thread dispatching automatically.
using UnityEngine;
using UnityEngine.Advertisements;
using System;
using System.Collections.Generic;
public class AdManager : MonoBehaviour, IUnityAdsShowListener, IUnityAdsLoadListener
{
public static AdManager Instance { get; private set; }
// 1. Strict State Management
public enum AdState
{
Ready,
Loading,
Showing,
ProcessingCallback
}
[SerializeField] private string _androidAdUnitId = "Rewarded_Android";
[SerializeField] private string _iosAdUnitId = "Rewarded_iOS";
private string _adUnitId;
private AdState _currentState = AdState.Ready;
// 2. One-Shot Actions to prevent Delegate Leaks
private Action _onComplete;
private Action _onFailed;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
// Determine platform ID
_adUnitId = (Application.platform == RuntimePlatform.IPhonePlayer)
? _iosAdUnitId
: _androidAdUnitId;
LoadAd();
}
public void LoadAd()
{
// Prevent loading if we are already busy
if (_currentState == AdState.Loading || _currentState == AdState.Showing) return;
_currentState = AdState.Loading;
Advertisement.Load(_adUnitId, this);
}
/// <summary>
/// Attempts to show an ad with safe callback handling.
/// </summary>
public void ShowRewardedAd(Action onComplete, Action onFailed)
{
// 3. Gatekeeping: Physically prevent re-entry
if (_currentState != AdState.Ready)
{
Debug.LogWarning($"[AdManager] Cannot show ad. Current State: {_currentState}");
return;
}
// Assign callbacks
_onComplete = onComplete;
_onFailed = onFailed;
_currentState = AdState.Showing;
Advertisement.Show(_adUnitId, this);
}
#region Interface Implementations
public void OnUnityAdsShowComplete(string adUnitId, UnityAdsShowCompletionState showCompletionState)
{
// 4. Thread Safety: Dispatch to Main Thread
MainThreadDispatcher.Enqueue(() =>
{
if (adUnitId.Equals(_adUnitId) && showCompletionState.Equals(UnityAdsShowCompletionState.COMPLETED))
{
Debug.Log("[AdManager] Ad Finished. Rewarding user.");
HandleAdClosed(true);
}
else
{
Debug.LogWarning("[AdManager] Ad skipped or failed.");
HandleAdClosed(false);
}
});
}
public void OnUnityAdsShowFailure(string adUnitId, UnityAdsShowError error, string message)
{
MainThreadDispatcher.Enqueue(() =>
{
Debug.LogError($"[AdManager] Show Failure: {error} - {message}");
HandleAdClosed(false);
});
}
// Required Boilerplate
public void OnUnityAdsShowStart(string adUnitId) { }
public void OnUnityAdsShowClick(string adUnitId) { }
public void OnUnityAdsAdLoaded(string adUnitId)
{
_currentState = AdState.Ready;
}
public void OnUnityAdsFailedToLoad(string adUnitId, UnityAdsLoadError error, string message)
{
// Reset state so we can try again later
_currentState = AdState.Ready;
}
#endregion
// 5. Cleanup Logic
private void HandleAdClosed(bool success)
{
_currentState = AdState.ProcessingCallback;
if (success)
{
_onComplete?.Invoke();
}
else
{
_onFailed?.Invoke();
}
// CRITICAL: Clear delegates to prevent memory leaks and double-firing
_onComplete = null;
_onFailed = null;
// Reset state and preload the next one
_currentState = AdState.Ready;
LoadAd();
}
}
The Missing Link: MainThreadDispatcher
Because SDKs run on background threads, you cannot invoke Game Logic (which touches Unity functionality) directly from OnUnityAdsShowComplete. You need a Dispatcher.
If you do not have one in your project, create this script and attach it to a permanent GameObject:
using UnityEngine;
using System.Collections.Generic;
using System;
public class MainThreadDispatcher : MonoBehaviour
{
private static readonly Queue<Action> _executionQueue = new Queue<Action>();
public static void Enqueue(Action action)
{
lock (_executionQueue)
{
_executionQueue.Enqueue(action);
}
}
void Update()
{
lock (_executionQueue)
{
while (_executionQueue.Count > 0)
{
_executionQueue.Dequeue().Invoke();
}
}
}
}
Why This Implementation Fixes The Loop
1. State Locking (_currentState)
In the ShowRewardedAd method, we check if (_currentState != AdState.Ready) return;.
This is the primary firewall. Even if your game logic accidentally calls ShowRewardedAd inside the Update() loop 60 times a second, the manager will reject 59 of those calls because the state immediately switches to Showing on the first valid call.
2. Delegate Clearing
In HandleAdClosed, we execute:
_onComplete = null;
_onFailed = null;
This ensures that the callback passed in for this specific ad can never be triggered twice. It decouples the ad logic from the game logic immediately after execution.
3. Thread Synchronization
By wrapping the SDK response in MainThreadDispatcher.Enqueue, we ensure that HandleAdClosed runs during the Unity Update cycle. This guarantees that frame-dependent logic (like unpausing the game or resuming audio) happens in the correct order, preventing race conditions where the game resumes before the ad overlay is fully destroyed.
Usage in Gameplay Code
Here is how you call this manager from your gameplay script without risking a loop. Note how we handle the pause state.
public void OnGameOver()
{
// Pause the game immediately
Time.timeScale = 0;
AdManager.Instance.ShowRewardedAd(
onComplete: () =>
{
// This runs strictly ONCE
Debug.Log("User earned a reward!");
RevivePlayer();
},
onFailed: () =>
{
// Handle failure (e.g., return to menu)
Debug.Log("Ad failed or skipped.");
ReturnToMenu();
}
);
}
private void RevivePlayer()
{
Time.timeScale = 1; // Unpause
// Reset player health, animations, etc.
}
Common Pitfalls and Edge Cases
The "Application Pause" Trap
Mobile games often pause when the ad overlay takes focus (application loses focus). Unity's OnApplicationPause(bool pause) is called automatically.
If you have logic in OnApplicationPause that forces the game to pause, ensure it differentiates between "User minimized the app" and "Ad SDK took focus." The Ad SDK usually handles the visual overlay; if you manually force Time.timeScale = 0 during an ad, ensure your ad callback explicitly sets Time.timeScale = 1.
Network Dropouts
If the internet cuts out while AdState.Loading, the SDK might hang. Ensure you implement the OnUnityAdsFailedToLoad interface (as shown above) to reset _currentState = AdState.Ready. Without this reset, your Ad Manager will stay stuck in Loading forever, blocking all future ads.
Conclusion
Preventing infinite ad loops requires treating your Ad System as a strict state machine rather than a collection of fire-and-forget method calls. By guarding your show calls with state checks, marshalling callbacks to the main thread, and aggressively cleaning up delegates, you ensure your revenue stream doesn't become a retention killer.