Quick answer: Abstract DateTime.Now behind an IClock interface so tests can fast-forward time instantly, write automated tests for the 20 known edge cases (DST, time zones, leap seconds, clock manipulation, offline, streak resets), and use server time as the source of truth whenever possible. Daily login bugs are almost entirely preventable with a one-hour refactor.

Daily login rewards look like a trivial feature. Check if 24 hours have passed since the last claim, increment a streak counter, give the player their reward. How hard could it be? Hard, as it turns out, because the 24-hour check is at the intersection of player device clocks, time zones, daylight saving time, leap seconds, and the human habit of traveling across borders. Here’s the test coverage you need to ship a daily login system that doesn’t generate a flood of bug reports.

Why Daily Rewards Are a Bug Factory

Look at any successful mobile game’s subreddit. You’ll find a recurring pattern: “I lost my 47-day streak after the time change,” “My reward didn’t reset when the day changed,” “Got the same daily twice,” “Claimed reward but didn’t receive it.” These complaints are the tip of an iceberg of design and implementation bugs. The iceberg exists because:

Step 1: Abstract Time

The first step — and the one most teams skip — is to remove every direct call to DateTime.Now, Time.time, or equivalent from your reward logic. Replace them with an injected clock interface that tests can control.

public interface IClock
{
    DateTime UtcNow { get; }
    DateTimeOffset PlayerLocalNow { get; }
    TimeZoneInfo PlayerTimeZone { get; }
}

public class SystemClock : IClock
{
    public DateTime UtcNow => DateTime.UtcNow;
    public DateTimeOffset PlayerLocalNow => DateTimeOffset.Now;
    public TimeZoneInfo PlayerTimeZone => TimeZoneInfo.Local;
}

public class TestClock : IClock
{
    public DateTime UtcNow { get; set; }
    public DateTimeOffset PlayerLocalNow { get; set; }
    public TimeZoneInfo PlayerTimeZone { get; set; }

    public void AdvanceBy(TimeSpan span) {
        UtcNow += span;
        PlayerLocalNow = PlayerLocalNow.Add(span);
    }
}

public class DailyRewardService
{
    private readonly IClock _clock;
    private readonly IRewardStorage _storage;

    public DailyRewardService(IClock clock, IRewardStorage storage) {
        _clock = clock;
        _storage = storage;
    }

    public ClaimResult TryClaim(PlayerId player)
    {
        var state = _storage.Load(player);
        var now = _clock.UtcNow;
        var today = now.Date;

        if (state.LastClaimDate == today) {
            return ClaimResult.AlreadyClaimedToday;
        }

        if (state.LastClaimDate == today.AddDays(-1)) {
            state.Streak++;
        } else if (state.LastClaimDate < today.AddDays(-1)) {
            state.Streak = 1;  // streak broken
        }

        state.LastClaimDate = today;
        _storage.Save(player, state);

        return ClaimResult.Success(state.Streak);
    }
}

Step 2: Cover the 20 Edge Cases

Here are the tests you need. Each one uses the mocked clock to simulate a time scenario instantly.

[Test]
public void FirstClaim_GivesStreakOne()
{
    var clock = new TestClock { UtcNow = new DateTime(2026, 4, 9, 12, 0, 0, DateTimeKind.Utc) };
    var svc = new DailyRewardService(clock, new InMemoryStorage());
    var result = svc.TryClaim(PlayerId.New());
    Assert.AreEqual(1, result.Streak);
}

[Test]
public void ClaimTwiceSameDay_SecondFails()
{
    var clock = new TestClock { UtcNow = new DateTime(2026, 4, 9, 12, 0, 0, DateTimeKind.Utc) };
    var svc = new DailyRewardService(clock, new InMemoryStorage());
    var player = PlayerId.New();
    svc.TryClaim(player);
    clock.AdvanceBy(TimeSpan.FromHours(5));
    var result = svc.TryClaim(player);
    Assert.AreEqual(ClaimResult.AlreadyClaimedToday, result.Code);
}

[Test]
public void ClaimNextDay_StreakIncreases()
{
    var clock = new TestClock { UtcNow = new DateTime(2026, 4, 9, 23, 0, 0, DateTimeKind.Utc) };
    var svc = new DailyRewardService(clock, new InMemoryStorage());
    var player = PlayerId.New();
    svc.TryClaim(player);
    clock.AdvanceBy(TimeSpan.FromHours(2));  // now April 10 01:00 UTC
    var result = svc.TryClaim(player);
    Assert.AreEqual(2, result.Streak);
}

[Test]
public void SkipADay_StreakResetsToOne()
{
    var clock = new TestClock { UtcNow = new DateTime(2026, 4, 9, 12, 0, 0, DateTimeKind.Utc) };
    var svc = new DailyRewardService(clock, new InMemoryStorage());
    var player = PlayerId.New();
    svc.TryClaim(player);
    clock.AdvanceBy(TimeSpan.FromDays(2));  // skip April 10
    var result = svc.TryClaim(player);
    Assert.AreEqual(1, result.Streak);
}

[Test]
public void DSTSpringForward_DoesNotBreakStreak()
{
    // US DST transition: March 8, 2026 2am -> 3am
    var clock = new TestClock { UtcNow = new DateTime(2026, 3, 7, 20, 0, 0, DateTimeKind.Utc) };
    var svc = new DailyRewardService(clock, new InMemoryStorage());
    var player = PlayerId.New();
    svc.TryClaim(player);
    // Next day after DST transition
    clock.AdvanceBy(TimeSpan.FromDays(1));
    var result = svc.TryClaim(player);
    Assert.AreEqual(2, result.Streak, "Streak should survive DST transition");
}

[Test]
public void DSTFallBack_DoesNotDoubleClaim()
{
    // US DST transition: November 1, 2026 2am -> 1am
    var clock = new TestClock { UtcNow = new DateTime(2026, 10, 31, 20, 0, 0, DateTimeKind.Utc) };
    var svc = new DailyRewardService(clock, new InMemoryStorage());
    var player = PlayerId.New();
    svc.TryClaim(player);
    clock.AdvanceBy(TimeSpan.FromHours(10));  // still same UTC day
    var result = svc.TryClaim(player);
    Assert.AreEqual(ClaimResult.AlreadyClaimedToday, result.Code);
}

// ...continue for: time zone change mid-streak, clock going backward,
// unix epoch clock, year boundary, leap day, offline play, server down,
// simultaneous claims (race), storage corruption, etc.

Step 3: Server Time vs Client Time

If your game has a backend, use server time. Client clocks are trivially manipulated — every player can change their device clock to get 365 daily rewards in a minute. Even when players aren’t cheating, device clocks drift, reset to 1970 when batteries die, and get confused on travel. The server clock is your source of truth.

For fully offline games, use the client clock but defend against manipulation:

public class OfflineClockGuard
{
    public bool IsClockTrustworthy(IClock clock, PlayerState state)
    {
        // Clock must be monotonically increasing
        if (clock.UtcNow < state.LastSeenTime) {
            return false;  // clock went backward
        }

        // Reject clocks before reasonable epoch
        if (clock.UtcNow < new DateTime(2025, 1, 1)) {
            return false;  // clock reset to past
        }

        // Reject clocks too far in the future
        if (clock.UtcNow > state.LastSeenTime.AddDays(90)) {
            return false;  // implausible jump forward
        }

        return true;
    }
}

When the clock fails the trust check, don’t crash or accuse the player of cheating. Silently pause the daily reward system and wait for the clock to normalize. Players with bad clocks hate being blamed, and the silent failure mode doesn’t generate bug reports.

Step 4: Handle Storage Failures Gracefully

The worst daily reward bugs are the ones where a player claims a reward but the storage write fails. The player thinks they claimed, the game thinks they didn’t, and when they refresh they can claim again — or worse, they see no reward at all and lose their streak.

Wrap the claim in a transaction: grant the reward and update the streak in a single atomic operation. If either step fails, roll back both. Test this by injecting storage failures in your test clock and verifying the player either gets the full reward or none at all.

Related Issues

For broader event and feature testing, see How to Test Game Tutorials for Bugs. For time-sensitive logic in multiplayer games, check Testing Cross-Platform Multiplayer Games. For save state edge cases in general, see How to Debug Game Save Corruption Bugs.

Daily rewards break at midnight on March 8 and November 1. Set a calendar reminder now.