Sky: Children of the Light drops a crystal shard into one of five realms on a repeating
schedule. Players want to know one thing: is there a shard right now, where, and how
long do I have. Building a live tracker for that looks like a
setInterval and some arithmetic. It is not, and the reasons are the same reasons every
recurring-event feature you will ever ship is harder than it looks.
The bug you will write first
Here is the intuitive implementation, and it is wrong:
const CYCLE_HOURS = 8;
const next = new Date(lastEruption.getTime() + CYCLE_HOURS * 3600_000);
Adding 8 hours in milliseconds computes an absolute interval. But an in-game schedule
is almost always defined in a civil timezone — "every day at 09:00 and 17:00 in the
game's reference zone." Those are different things, and they diverge exactly twice a year.
On a DST transition day, one civil day is 23 or 25 hours long. Your millisecond
arithmetic sails straight through and lands an hour off. Every user in the affected zone
sees the wrong countdown, on the one day when a wrong countdown is most confusing.
The fix is to do the arithmetic in the reference zone's civil calendar and convert to an
instant afterwards — not the other way around. In practice:
// wrong: absolute offset, ignores civil calendar
base.getTime() + n * 86400_000
// right: civil-day arithmetic, then resolve to an instant
Temporal.ZonedDateTime.from({...}).add({ days: n })
If you cannot use Temporal yet, Intl.DateTimeFormat with an explicit timeZone and
formatToParts will get you the reference zone's wall-clock fields, which is enough to
do the same thing by hand.
Three clocks, and only one of them is trustworthy
A countdown has more clocks in it than you expect:
- The game's schedule clock — fixed, defined by the developers, in one zone
- The user's device clock — arbitrary, and frequently wrong
- The user's timezone setting — arbitrary, and sometimes deliberately fake
Number 2 is the one that ruins you. A meaningful fraction of devices have clocks off by
minutes; some are off by hours or years. If the countdown is pure client arithmetic from
Date.now(), those users see a timer that is confidently, silently wrong.
You have two honest options: fetch a server timestamp once and compute an offset, or
accept the drift and be explicit that the timer follows the device clock. What you should
not do is compute from the device clock and present it as authoritative.
Timezone (number 3) is a different problem: it is not wrong, it is just theirs. The
schedule is defined in the reference zone; the display belongs in the user's. Keep both,
and never store the converted value — store the instant, convert at render time.
"Today" is a question with a bad answer
A tracker naturally wants to say "today's shard is in Hidden Forest." But today for whom?
If the schedule rolls over at midnight in the reference zone, a player in a zone many
hours ahead is looking at "today's" shard while it is still yesterday there — or the
reverse. Both are correct statements about different days.
Three approaches, each with a real cost:
Reference-zone day consistent for everyone, confusing for the user
Local day matches the user's intuition, hard to discuss in a group
Next N events no "day" concept at all, no ambiguity, less scannable
The third is underrated. "The next four eruptions, with countdowns" sidesteps the whole
question and happens to be what the player actually wants — they are not planning a
calendar week, they are deciding whether to log in now. A day-based view is the
navigational fallback, not the primary answer.
Countdown rendering is its own trap
Once the math is right, the display can still be wrong in ways that feel like bugs:
-
setInterval(fn, 1000)drifts. Timers fire late, the page throttles in a background tab, and after twenty minutes your seconds column is visibly behind. Compute the remaining time from a timestamp every tick rather than decrementing a counter. -
Backgrounded tabs freeze. Mobile browsers suspend timers aggressively. Recompute on
visibilitychange, or a user who switches apps and comes back sees a stale countdown that has "stopped." -
Sub-second rounding lies. If you
Math.floorthe seconds, the timer sits on "1" for a moment and then jumps to "expired" — reading as a skipped second. Round rather than floor, or render at the boundary.
None of these are hard. All of them get reported as "the timer is broken" if you skip
them, and each one is invisible in a five-minute test.
What survives from all of this
- Do recurring arithmetic on civil calendars in the schedule's own timezone, then resolve to instants. Never add fixed millisecond offsets across day boundaries.
- Treat the device clock as untrusted input, and say so if you are relying on it.
- Prefer "the next N events" over "today's event." It avoids a genuine ambiguity rather than picking a side.
- Recompute the countdown from a timestamp on every tick and on
visibilitychange.
The shard tracker exists because doing this correctly once is
worth more than every player doing the timezone conversion in their head every day. That
is basically the whole value proposition of every schedule tool ever built — someone has
to eat the complexity, and it should not be the person who just wants to know whether to
log in.
Top comments (0)