MarbleMaze: Currency & Life Regeneration
MarbleMaze: Currency & Life Regeneration

MarbleMaze: Currency & Life Regeneration

Created on:
Team Size: 2
Time Frame: 3 Months
Tool Used: Unity/C#

MarbleMaze tracks five currencies β€” coins, stars, hearts, rockets, and UFOs β€” through a single Dictionary<CoinType, int>. Hearts regenerate over real-world time using DateTime comparison rather than running a game-time timer, which means they accumulate correctly while the app is closed. A parallel event system drives all HUD updates so CoinManager never touches UI directly.

The Currency Store

CoinManager maintains two parallel dictionaries β€” current amounts and previous amounts:

private Dictionary<CoinType, int> coins = new Dictionary<CoinType, int>();
private Dictionary<CoinType, int> previousCoins = new Dictionary<CoinType, int>();

previousCoins exists solely for the HUD animation system: OnCoinChanged broadcasts both values so CurrencyPannel can tween the displayed number from the old value to the new one. Any time a currency is written, LevelPreviousCoinAmount levels the two dictionaries back into sync:

public void LevelPreviousCoinAmount(CoinType type)
{
    previousCoins[type] = coins[type];
}

Adding a new currency type requires only a new enum entry and a coins.Add in Start β€” no other code changes.

Events: Decoupled UI Updates

CoinManager exposes four events and no direct UI references:

public event Action<CoinType, int, int> OnCoinChanged;  // type, newValue, previousValue
public event Action<CoinType, int>      OnCoinSet;       // type, value (hard set, no animation)
public event Action<TimeSpan>           OnHeartTimerTick;
public event Action<TimeSpan>           OnCoinTimerTick;

OnCoinChanged carries both the new and previous value so subscribers can decide whether to animate. OnCoinSet is used during session restore when the displayed value should snap immediately rather than tween. Every UI panel subscribes in OnEnable and unsubscribes in OnDisable β€” no persistent references:

protected virtual void OnEnable()
{
    coinManagerRef = CoinManager.Instance;
    coinManagerRef.OnCoinSet     += SetCurrencyValue;
    coinManagerRef.OnCoinChanged += UpdateCurrencyValue;
}
protected virtual void OnDisable()
{
    coinManagerRef.OnCoinSet     -= SetCurrencyValue;
    coinManagerRef.OnCoinChanged -= UpdateCurrencyValue;
}

Heart Regeneration: DateTime Arithmetic

Hearts regenerate at a fixed rate (timeToRegainHeartInMinutes) using two DateTime fields persisted across sessions. The core logic sits in RecalculateHearts, called every frame while the app is focused:

private void RecalculateHearts()
{
    if (coins[CoinType.HEART] >= maxHeartAmount)
    {
        coins[CoinType.HEART] = maxHeartAmount;
        currentMinutesUntilFullHearts = -1;
        return;
    }

    DateTime now = DateTime.UtcNow;
    TimeSpan elapsed = now - lastHeartRefillTime;

    int heartsToAdd = (int)(elapsed.TotalMinutes / timeToRegainHeartInMinutes);

    if (heartsToAdd <= 0)
        return;

    int totalHeartsAmount = Mathf.Min(coins[CoinType.HEART] + heartsToAdd, maxHeartAmount);
    SetCurrencyAmount(CoinType.HEART, totalHeartsAmount);

    // Advance the refill timestamp by exactly the time consumed
    lastHeartRefillTime = lastHeartRefillTime.AddMinutes(
        heartsToAdd * timeToRegainHeartInMinutes
    );
}

The key is the last line: lastHeartRefillTime is advanced by heartsToAdd * interval, not set to DateTime.UtcNow. This preserves sub-interval remainder time β€” if the player was owed 1.7 hearts, the 0.7 partial interval carries forward into the next calculation rather than being discarded. The result is mathematically exact regeneration regardless of call frequency.

Refill Timer: When It Starts

The refill timer only starts when the heart count drops from the maximum:

public void ReduceCurrencyAmount(CoinType type, int amount)
{
    coins[type] -= amount;

    if (type == CoinType.HEART)
    {
        if (coins[CoinType.HEART] == maxHeartAmount - 1)
        {
            // Start refill timer ONLY when dropping from max
            lastHeartRefillTime = DateTime.UtcNow;
        }
        RecalculateHearts();

        if (coins[CoinType.HEART] == 0)
            FirebaseManager.Instance.LogEvent("no_hearts_left");
    }

    OnCoinChanged?.Invoke(type, coins[type], previousCoins[type]);
    LevelPreviousCoinAmount(type);
}

If the player already has fewer than the maximum, the existing lastHeartRefillTime is preserved and continues to accumulate β€” losing a second heart doesn’t reset the timer for the first.

Live Countdown: OnHeartTimerTick

A coroutine fires every real second and emits both timers as TimeSpan values. UI panels subscribe to format and display them without any polling:

IEnumerator TimerTickRoutine()
{
    var wait = new WaitForSecondsRealtime(1f);

    TimeSpan remainingHeartTime = TimeUntilNextHeart();
    OnHeartTimerTick?.Invoke(remainingHeartTime);

    TimeSpan remainingCoinVideoTime = TimeUntilNextCoinVideo();
    OnCoinTimerTick?.Invoke(remainingCoinVideoTime);

    yield return wait;
}

public TimeSpan TimeUntilNextHeart()
{
    if (coins[CoinType.HEART] >= maxHeartAmount)
        return TimeSpan.Zero;

    DateTime nextHeartTime = lastHeartRefillTime.AddMinutes(timeToRegainHeartInMinutes);
    return nextHeartTime > DateTime.UtcNow ? nextHeartTime - DateTime.UtcNow : TimeSpan.Zero;
}

The coroutine is restarted each Update frame so it’s always in sync with the focused-app check β€” the timer only ticks while Application.isFocused is true.

Rewarded Video Safety Window

Hearts and coins can be granted by rewarded video ads, but rapid re-claims must be prevented. Both RewardHearts and RewardCoins check a shared rewardedVideoSafeTime countdown:

public void RewardHearts(int amount)
{
    if (rewardedVideoSafeTime > 0) return;
    rewardedVideoSafeTime = 20.0f;

    coins[CoinType.HEART] += amount;
    RecalculateHearts();

    OnCoinChanged?.Invoke(CoinType.HEART, coins[CoinType.HEART], previousCoins[CoinType.HEART]);
    LevelPreviousCoinAmount(CoinType.HEART);

    SavingManager.Instance.SavePlayer();
}

rewardedVideoSafeTime is a float decremented every Update tick. The 20-second window gives enough time for any pending ad callbacks to fire without triggering a double-grant if the user taps the reward button twice in quick succession. Coins have an additional lastVideoRewardTime persisted to disk, enforcing a 4-hour cooldown across sessions:

public TimeSpan TimeUntilNextCoinVideo()
{
    DateTime nextPossibleVideo = lastVideoRewardTime.AddHours(hoursBetweenRewardedCoins);
    return nextPossibleVideo > DateTime.UtcNow ? nextPossibleVideo - DateTime.UtcNow : TimeSpan.Zero;
}

LifeManager: In-Game Heart Representation

CoinManager tracks the full persistent heart count (up to the configured maximum). LifeManager wraps it with an in-game cap of 3, so the player can have 15 hearts stored but only 3 on any given level:

public void ResetLife()
{
    currentLife = Mathf.Clamp(
        CoinManager.Instance.GetCoinAmount(CoinType.HEART), 0, 3);
}

ResetLife is called at the start of each level and at the end panel before returning to the menu, so currentLife always reflects the current heart balance up to the in-game maximum. Losing a life reduces both currentLife and the persistent CoinType.HEART amount simultaneously:

public void RemoveLife()
{
    if (currentLife > 0)
    {
        currentLife--;
        CoinManager.Instance.ReduceCurrencyAmount(CoinType.HEART, 1);
    }

    LevelManager.Instance.IncreaseLivesLostToThisLevel();
    OnLifeRemoved?.Invoke();
}

OnLifeRemoved is what PlayerMovement subscribes to in order to increase its ground-detection sphere radius after each life lost β€” a mechanical accessibility assist that comes naturally from the existing event without any additional coupling.

← Back to Project Overview Next: Manager Architecture & Event System β†’
© 2026 Samuel Styles