Daily puzzle games are everywhere now, and they all seem to need a server. Something has to decide today's puzzle, store how far each player got, and keep streaks in sync between visits. When I set out to build Poople, a daily word ladder where you change a 4-letter word into POOP one letter at a time, I kept sketching that same backend, and it kept feeling like too much plumbing for a game you finish in under a minute.
So I tried to delete it. The finished game has no backend and no database. There is no API that returns today's puzzle, no table of user progress, and no login. It is static files on a CDN plus a little localStorage. This post is how that works, including two gotchas that took me a few tries to get right.
The date is the only shared state you need
Here is the core idea. Every player already shares one thing: the clock. If today's puzzle is a pure function of the current date, then every browser in the world computes the same puzzle on its own, and no server has to choose it or hand it out.
Concretely, I count whole days since a fixed starting moment. That single integer is both the puzzle number you see and the index into a fixed list of starting words.
const DAY_MS = 86_400_000;
// Whole days since a fixed epoch. This one integer is both the puzzle
// number and the index into the list of starting words.
function daysSinceEpoch(nowMs = Date.now()): number {
if (nowMs <= EPOCH_UTC) return 0;
return Math.floor((nowMs - EPOCH_UTC) / DAY_MS);
}
function getStartWord(startWords: string[], dayIndex = daysSinceEpoch()): string {
const len = startWords.length;
return startWords[((dayIndex % len) + len) % len];
}
Open the page in New York, London, or Tokyo at the same moment and you all get the same starting word, because you are all dividing the same UTC timestamp by the same number.
Gotcha 1: time zones, and where the day flips
The word UTC in that last sentence is doing a lot of work. If you key the puzzle off local time, players in different zones get different puzzles, and the promise that everyone solves the same one falls apart. Date.now() returns a UTC millisecond count, so as long as every comparison stays in UTC, the whole world stays in sync.
The one decision left is when the day rolls over. Midnight UTC is the obvious choice, but it lands in the middle of the afternoon in East Asia and very early morning in the Americas. I wanted the flip to feel like a fresh morning for the largest span of players, so I baked an 08:00 UTC offset straight into the epoch.
// The day flips at 08:00 UTC, the same instant for every player on earth.
const EPOCH_UTC = Date.UTC(2025, 7, 14, 8, 0, 0, 0); // 2025-08-14 08:00 UTC
Because the offset lives in the epoch, the rest of the math stays a plain division. There is no special casing, and no place to accidentally mix a local date with a UTC one, which is exactly the off-by-one that bites most people the first time.
Gotcha 2: determinism is safe to server-render, randomness is not
This game runs on Next.js, so pages are server rendered and then hydrated on the client. That detail interacts with the date trick in one nice way and one sharp way.
The nice way: because the daily start word is deterministic, the server and the client compute the exact same value, so there is no hydration mismatch. The first paint already shows the correct puzzle.
The sharp way shows up in the practice mode, where I wanted a random starting word instead of the daily one. If you call Math.random() during render, the server picks one word and the client picks another, and React flags the mismatch. The fix is to treat the random pick as a client-only effect, so the server renders a stable value and the client swaps in the random word after mount.
// Daily mode: the start word is deterministic, so it is safe to compute
// during server rendering. Server and client agree.
const [startWord, setStartWord] = useState(
() => (options.startWord ?? getStartWord(startWords)).toLowerCase(),
);
// Unlimited mode: a random word would differ between server and client,
// so pick it only after mount, on the client.
useEffect(() => {
if (mode !== "unlimited") return;
const i = Math.floor(Math.random() * startWords.length);
setStartWord(startWords[i]);
}, [mode]);
That useEffect is the only place the game calls Math.random() on the initial mount. Everything else is a pure function of the date, which is what keeps server rendering boring in the best possible way.
State that never leaves the device
With the puzzle handled, the other thing a daily game usually reaches for a database to do is player state: wins, streaks, and stats. I keep all of it in localStorage, behind a small wrapper that fails silently so a private window or a disabled-storage setting never throws.
function read<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback; // server render
try {
const v = window.localStorage.getItem(key);
return v == null ? fallback : (JSON.parse(v) as T);
} catch {
return fallback; // private mode, quota, or storage disabled
}
}
Streaks are the interesting case, because a streak is a claim about time. Mine reuses the very same day math as the puzzle. A streak survives if your last win was today or yesterday, and it resets the moment you miss a full day.
// Reset the streak only if the last win was neither today nor yesterday.
function maybeBreakStreak(nowMs = Date.now()): void {
const lastWon = getTimeLastWon(); // from localStorage
if (lastWon <= 0) return;
const today = gameDayOf(nowMs); // same days-since-epoch value
const lastDay = gameDayOf(lastWon);
if (lastDay !== today && lastDay !== today - 1) setStreak(0);
}
The part worth stealing is that there is exactly one definition of a day, gameDayOf, shared by both the puzzle selection and the streak logic. When those two drift apart, you get the classic bug where the puzzle has rolled over but the streak still thinks it is yesterday. Sharing the function makes that bug impossible.
One deterministic engine, three pages
Once the engine is a pure function of a seed, you get more than one game out of it almost for free. I ship three pages from the same core, just by changing what feeds the seed and whether anything is saved.
The daily game seeds from the date and saves your streak. Poople Unlimited is the same engine with the date seed swapped for a random one, and it saves nothing, so you can warm up without touching your streak. Poople Answer is an archive of past puzzles, generated ahead of time from the same precomputed data, so it is static as well. Three pages, one engine, zero servers.
What you give up
This pattern has real tradeoffs, and it helps to name them before you adopt it.
You lose server authority. Because scoring and validation run on the client, you cannot stop a determined player from editing their own localStorage, and you cannot build a trustworthy global leaderboard without adding a backend. For a casual daily puzzle that is fine, since the only person anyone can cheat is themselves.
You lose cross-device sync. Stats live in one browser, so they do not follow a player to their phone. Accounts would fix that, and accounts are exactly the thing I was trying to avoid.
What you gain is a game that is genuinely just static assets. There are no cold starts, no database to scale, no sync service to page you at 2 a.m., and the hosting cost rounds down to nothing. For daily content that the whole audience consumes at the same time, a CDN is a wonderful place to live.
Wrapping up
The lesson I took away is that the date was all the shared state the game actually needed. Once the puzzle is a pure function of time and the player state stays on the device, the server you were about to build turns out to be optional.
If you have shipped daily or time-based content, I would love to hear how you handled it. Did you go server-authoritative, or push it to the client like this? And has anyone else been burned by the UTC rollover the first time around? You can try the result at Poople, and I will be in the comments.


Top comments (0)