Wordle made the "everyone gets the same puzzle today" idea famous. The nice surprise is that you don't need a backend for it. If your game logic is deterministic, the date itself can be the source of the puzzle.
On PlayTrova, a free family games site, every game has a daily solo challenge built this way: today's Lucky Lanterns dice rolls, today's Mosha Zap mosquito swarm and today's Checkers puzzles are identical for every player in the world. The site is fully static, so there's no database deciding what "today" contains.
Note: PlayTrova was built with an AI coding assistant, and this article was drafted with its help.
Here's the whole recipe in four small pieces.
1. Agree on what "today" means
Players live in different time zones, so pick one clock. UTC is the simplest:
/** UTC date key, e.g. "2026-10-26". */
export const todayKey = (d = new Date()) => d.toISOString().slice(0, 10);
Everyone's daily switches at the same moment (midnight UTC), and the key doubles as a label for share text and saved results.
2. Turn the date into a number
A seeded random generator needs a 32-bit seed, so hash the string. Mixing the game id in means two games never share a seed on the same day:
/** Stable 32-bit string hash. */
export function hashStr(s: string): number {
let h = 1779033703 ^ s.length;
for (let i = 0; i < s.length; i++) {
h = Math.imul(h ^ s.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return h >>> 0;
}
/** Seed for a game's daily challenge. */
export const dailySeed = (gameId: string, key = todayKey()) =>
hashStr(`${gameId}:${key}:pt1`);
The :pt1 suffix is a version tag. If you ever change how puzzles are generated, bump it and nobody gets a half-old, half-new puzzle.
3. Use a seedable PRNG instead of Math.random
Math.random() can't be seeded, so every player would get something different. mulberry32 is tiny, fast and good enough for games:
export function mulberry32(seed: number): () => number {
let a = seed | 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rng = mulberry32(dailySeed("lucky-lanterns"));
rng(); // same sequence for everyone today
4. Keep the game logic deterministic
This is the part that actually takes discipline. If anything inside the game logic calls Math.random() or Date.now(), two players drift apart after a few moves. On PlayTrova the rule is simple: turn-based game logic is pure, and randomness comes only from a seed stored in the game state. A test makes the guarantee explicit:
it("gives everyone the same daily puzzles", () => {
expect(dailyPuzzles(dailySeed("checkers", "2026-10-22")))
.toEqual(dailyPuzzles(dailySeed("checkers", "2026-10-22")));
expect(dailyPuzzles(dailySeed("checkers", "2026-10-23")))
.not.toEqual(dailyPuzzles(dailySeed("checkers", "2026-10-22")));
});
For puzzles you also want "solvable" to be guaranteed, so generate candidates from the seed and have a solver check each one, in a test that runs over many dates.
The payoff: sharing without a backend
Because everyone played the same thing, results are comparable, and a share string is all you need. Lucky Lanterns shares a little grid of 🏮 banked and 💨 gusted turns, Wordle-style. Paste it in a group chat and your friends know exactly what you're bragging about.
What you give up: there's no global leaderboard (that needs a server) and a determined player can peek at tomorrow by changing their clock. For a family games site, that trade-off is fine.
Try it
Today's challenges for every game are on the PlayTrova daily page. If you've built something similar, I'd love to hear how you handled time zones and puzzle quality.
Top comments (0)