<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dilip Panicker</title>
    <description>The latest articles on DEV Community by Dilip Panicker (@dilippanicker).</description>
    <link>https://dev.to/dilippanicker</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4240836%2Fa3085767-43d3-433b-95ce-5b15ce5f9adc.png</url>
      <title>DEV Community: Dilip Panicker</title>
      <link>https://dev.to/dilippanicker</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dilippanicker"/>
    <language>en</language>
    <item>
      <title>Picking daily words without a server</title>
      <dc:creator>Dilip Panicker</dc:creator>
      <pubDate>Thu, 12 Nov 2026 10:43:39 +0000</pubDate>
      <link>https://dev.to/dilippanicker/picking-daily-words-without-a-server-2al0</link>
      <guid>https://dev.to/dilippanicker/picking-daily-words-without-a-server-2al0</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first version
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;Math.imul&lt;/code&gt;, and sliced bit ranges out of the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dayMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2654435761&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;easy&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x7FF&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;hard&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x7FF&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;extreme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x7FF&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  What was wrong
&lt;/h2&gt;

&lt;p&gt;There are two problems, and they come from the same place.&lt;/p&gt;

&lt;p&gt;The first is range. &lt;code&gt;&amp;amp; 0x7FF&lt;/code&gt; 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: &lt;code&gt;seed &amp;gt;&amp;gt;&amp;gt; 22&lt;/code&gt; leaves only 10 bits, so it topped out at 1,023.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;dayMs&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;My tests caught the collisions and the unreachable answers. I worked out the two-word Easy afterwards, by re-running the old code.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I found it
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;ABACK&lt;/code&gt; for both Easy and Hard. Over ten years of dates that happened on 8 days. And 267 answers were unreachable because of the &lt;code&gt;&amp;amp; 0x7FF&lt;/code&gt; mask.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;[0, 1024]&lt;/code&gt;. That also explains the &lt;code&gt;ABACK&lt;/code&gt; collision: index 0 is &lt;code&gt;ABACK&lt;/code&gt;, so a day where Hard landed on 0 or 1024 hit Easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix (v1.5.8)
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dailyIndices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dayNum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;rand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mulberry32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dayNum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2654435761&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Easy, Hard and Extreme are the first, second and third draws, so they are distinct by construction, and every index in &lt;code&gt;[0, n)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests
&lt;/h2&gt;

&lt;p&gt;The tests I added check three things:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you pick a day's word without a server, seed from a day number, not a timestamp.&lt;/p&gt;

&lt;p&gt;I built Wordout mostly with Claude Code.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Play in the browser: &lt;a href="https://dilippanicker.github.io/wordout/play/" rel="noopener noreferrer"&gt;https://dilippanicker.github.io/wordout/play/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Play: &lt;a href="https://play.google.com/store/apps/details?id=com.dilippanicker.wordout" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.dilippanicker.wordout&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source on GitHub: &lt;a href="https://github.com/dilippanicker/wordout" rel="noopener noreferrer"&gt;https://github.com/dilippanicker/wordout&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gamedev</category>
      <category>reactnative</category>
      <category>ai</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
