DEV Community

Mudassir Hussain
Mudassir Hussain

Posted on AI-assisted

Daily Games Don't Reset at Midnight: The Time zone Bug That Broke My Streaks

When I launched my daily word game, three players emailed me on the same morning — from three different countries — each insisting they were playing a different "today's game." They were all right. And that was the bug.

I run a small family of browser word games — Dive Krillion (the daily one), Krillion Infinite (endless), and Noillirk (the backwards edition). Same prompt-and-rarity engine, three sites. The hardest problem in the whole project wasn't the game. It was midnight.

The naive version (don't do this)

My first reset logic was one line of thinking: the day changes when the player's clock says so. Device-local midnight. It failed in every direction at once:

  • Players in different timezones were on different dives, so scores and the daily leaderboard compared people who had played different prompts.
  • Anyone could change their phone's date and unlock tomorrow's dive early.
  • "Today" on the share card didn't match "today" on the website.

The other naive option — server time (UTC) — is just as bad. Midnight UTC is 5 AM in Pakistan and the previous evening in New York. Half your players get the new daily game at a time that feels completely random.

Fix #1: one game, one canonical midnight

Dive Krillion is a shared daily ritual: everyone on Earth plays the same dive on the same day. That only works if "today" is defined in exactly one place. I picked a canonical timezone — midnight Eastern Time — and the whole game (prompt selection, streaks, leaderboard, the on-site countdown) runs on it.

The prompt itself is just an index into a 302-prompt bank: the number of calendar days since launch, computed in the canonical timezone. The trick that makes this reliable in JavaScript is to never do 24-hour arithmetic on timestamps. Days are not 86,400 seconds long — twice a year, in a DST-observing zone, they're 23 or 25 hours. Count calendar dates instead:

function diveIndex(date: Date, timeZone: string, epoch: string): number {
  const parts = new Intl.DateTimeFormat("en-US", {
    timeZone, year: "numeric", month: "2-digit", day: "2-digit",
  }).formatToParts(date);

  const get = (t: string) => Number(parts.find(p => p.type === t)!.value);
  const todayUtc = Date.UTC(get("year"), get("month") - 1, get("day"));

  const [ey, em, ed] = epoch.split("-").map(Number);
  const epochUtc = Date.UTC(ey, em - 1, ed);

  return Math.round((todayUtc - epochUtc) / 86_400_000);
}

Format the date in the target zone, rebuild it as a UTC calendar date, then subtract. DST can't hurt you, because you never measured hours — you counted dates.

Fix #2: the endless game gets the opposite rule

Krillion Infinite has no shared daily dive — it's personal, endless play. Forcing Eastern Time on it made no sense, so it resets at midnight, your local time. Same engine, opposite rule, chosen because the product is different: a shared ritual needs one clock; a personal game needs yours.

That split is written on both sites, in the FAQs, in plain language. Half of "timezone bugs" are actually expectation bugs — players forgive any rule except a hidden one.

The push-notification trap

The daily reminder push goes out once, globally, shortly after the canonical reset. That only works because Dive Krillion has one reset moment. Try to send a "your new dive is ready" push for a local-midnight game and you're either waking people at 3 AM in some zone or maintaining a per-timezone send queue. If you build a daily game, decide early which side of that trade you're on.

What I'd tell anyone building a daily game

  • Pick your reset semantics before your schema. Streaks, leaderboards, archives ("play yesterday's dive"), and notifications all inherit the decision.
  • Count calendar dates, never hours. The Intl.DateTimeFormat + Date.UTC pattern above is boring and correct, including on DST days.
  • One shared day = one timezone. Personal cadence = local time. Mixing them in one product confuses everyone.
  • Write the rule where players can see it. A countdown that says "Next dive in 04:12:33 (ET)" ends more support emails than any fix.

Try the three clocks

All free, no download. If you've shipped a daily game, I'm curious: which clock did you pick — and what did it break first?

Top comments (4)

Collapse
 
griffonknox profile image
Scott Thompson •

Nice write-up. Counting calendar dates instead of hours is a trap I've seen bite people, and the Intl.DateTimeFormat trick is a clean way around it.

To answer your question at the end, I'd lean toward local time for a daily game too. If each puzzle has a number and scores are stored against that number, players in different zones still land on the same leaderboard even though their day starts at different times. The server tracks one attempt per player per puzzle, so changing your phone's date just uses up tomorrow's try early. For reminders, a job every 15 minutes can find the zones that just hit the new day and notify those players at a sensible local hour. The board for each puzzle closes once the last time zone finishes that day, around noon UTC the next day.

A fixed clock still makes sense if you want everyone playing the same day with no spoilers, so I can see why you went that way. Curious if you considered puzzle numbers early on.

Collapse
 
mudassir_hussain_b5b2852b profile image
Mudassir Hussain •

Thanks Scott — glad the date-counting trick landed; it cost me a few grey hairs before it clicked.

Funny you mention puzzle numbers: under the hood, that's exactly what we have. Each dive is an index — days since launch into a 302-prompt bank — and streaks and records are stored against that index. The only choice we made on top was the unlock clock: one canonical midnight (ET) for the daily game, so dive #N opens for everyone on the same calendar day. Mostly for the reason you guessed: no spoilers, one shared leaderboard day, one reminder push.

Your per-zone reminder job is a neat pattern — and it's basically what our endless edition does: Krillion Infinite runs on local midnight, because there's no shared day to protect there. If the daily game ever gets a "your own timezone" mode, closing each puzzle's board around noon UTC the next day is the cleanest way I've heard to do it.

Appreciate you thinking it through this carefully.

Collapse
 
griffonknox profile image
Scott Thompson •

you bet, thank you for engaging with the discussion!

Collapse
 
davekurian profile image
Dave Kurian •

The phone-date exploit you listed under the naive version is worth rechecking against Fix #1. diveIndex(date, timeZone, epoch) fixes which calendar day it is in ET, but if date comes from the client’s new Date(), moving the device clock still unlocks tomorrow’s dive. Does the server compute the index (or reject a client index ahead of server ET), and is the streak update keyed to that server-validated index rather than the submit timestamp?