DEV Community

Dilip Panicker
Dilip Panicker

Posted on Edited on

Picking daily words without a server

Wordout is a Wordle-style game I built in React Native with Expo. It has three daily puzzles, Easy, Hard and Extreme, each with a different word, and everyone gets the same words on the same day. There is no backend; the app works the words out from the date.

The first version

The idea was to hash the date and read three indices out of the hash. I took UTC midnight as milliseconds, multiplied it by a big odd constant with Math.imul, and sliced bit ranges out of the result:

const seed = Math.imul(dayMs, 2654435761);
easy    = list[(seed >>> 0)  & 0x7FF]
hard    = list[(seed >>> 11) & 0x7FF]
extreme = list[(seed >>> 22) & 0x7FF]
Enter fullscreen mode Exit fullscreen mode

(That is the shape of it; the real code also had spare slices and a % list.length.)

What was wrong

There are two problems, and they come from the same place.

The first is range. & 0x7FF keeps 11 bits, so an index can never exceed 2,047, and my answer list has 2,315 words. About 267 answers could never be picked. Extreme was worse: seed >>> 22 leaves only 10 bits, so it topped out at 1,023.

The second is the input. UTC midnight in milliseconds is always a multiple of 86,400,000, and 86,400,000 is 1,024 × 84,375. So the bottom 10 bits of dayMs are always zero. Multiplying by an odd constant mod 2³² doesn't change how many trailing zero bits a number has, so the bottom 10 bits of the seed are zero too. Easy reads bits 0 to 10, which leaves exactly two possible values: index 0 or index 1024. Easy could only ever be one of two words.

My tests caught the collisions and the unreachable answers. I worked out the two-word Easy afterwards, by re-running the old code.

How I found it

I was writing invariant tests for the stores: executable versions of rules I had only written down in my notes. Testing the daily words against those rules turned up two real bugs. For example, for 27 January 2026 the old code picked ABACK for both Easy and Hard. Over ten years of dates that happened on 8 days. And 267 answers were unreachable because of the & 0x7FF mask.

For the two-word Easy, I took the old code back out of git history, ran it over ten years of dates and printed the set of Easy indices. It was [0, 1024]. That also explains the ABACK collision: index 0 is ABACK, so a day where Hard landed on 0 or 1024 hit Easy.

The fix (v1.5.8)

I stopped slicing bits from a hash of milliseconds. The new version works from the UTC day number, floor(midnight / 86400000), seeds a small PRNG called mulberry32 with it, and draws until it has three different indices:

export function dailyIndices(dayNum: number, n: number) {
  const rand = mulberry32(Math.imul(dayNum, 2654435761));
  const picked: number[] = [];
  while (picked.length < 3) {
    const v = Math.floor(rand() * n);
    if (!picked.includes(v)) picked.push(v);
  }
  return picked;
}
Enter fullscreen mode Exit fullscreen mode

Easy, Hard and Extreme are the first, second and third draws, so they are distinct by construction, and every index in [0, n) is reachable. It is a pure function of the day number and the list length, so the same day gives the same words on every device. The US list has 2,315 words and the UK list 2,314, and both go through the same code.

I didn't need a cutover date. Each day's answers are computed once on the first daily start and saved with the game state. A day already in progress keeps its word, and only later days use the new function.

The tests

The tests I added check three things:

  • The same day always gives the same words, and the next day gives different ones.
  • Over 10,000 simulated days, on both word lists, the three indices are distinct and inside the list.
  • Over the same 10,000 days the largest index seen is at least 2,048. That one exists only to catch the old & 0x7FF cap coming back.

What I took from it

The output looked random, so I never checked the structure of its input. The bugs showed up only once I wrote down what the function had to guarantee and tested it directly.

If you pick a day's word without a server, seed from a day number, not a timestamp.

I built Wordout mostly with Claude Code.

Top comments (0)