<?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: Solsticio</title>
    <description>The latest articles on DEV Community by Solsticio (@solsticioapp).</description>
    <link>https://dev.to/solsticioapp</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%2F4065708%2F1d87251b-4eff-467f-b16d-5912f463e635.png</url>
      <title>DEV Community: Solsticio</title>
      <link>https://dev.to/solsticioapp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/solsticioapp"/>
    <language>en</language>
    <item>
      <title>A daily logic puzzle with no backend at all</title>
      <dc:creator>Solsticio</dc:creator>
      <pubDate>Thu, 06 Aug 2026 10:59:54 +0000</pubDate>
      <link>https://dev.to/solsticioapp/a-daily-logic-puzzle-with-no-backend-at-all-1ef6</link>
      <guid>https://dev.to/solsticioapp/a-daily-logic-puzzle-with-no-backend-at-all-1ef6</guid>
      <description>&lt;p&gt;I built &lt;a href="https://solsticio.app/" rel="noopener noreferrer"&gt;Solsticio&lt;/a&gt;, a daily sun/moon logic puzzle in the&lt;br&gt;
Binairo/Takuzu family, and I set myself one engineering constraint: &lt;strong&gt;no backend&lt;/strong&gt;. The daily&lt;br&gt;
puzzle had to be identical for everyone — on the web, in the Android app, and in the social&lt;br&gt;
videos — without a server deciding anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  The date is the seed
&lt;/h2&gt;

&lt;p&gt;The whole thing rests on a generator that is deterministic given a seed, and a seed derived&lt;br&gt;
from the date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&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;specForDaily&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&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="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// First seed in [base, base+500) that yields a 6x6 board of difficulty 5..7.&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;s&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxTier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TIER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CONTRA1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;s&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="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;difficulty&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;difficulty&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// Fallbacks: first solvable board, then the base seed itself.&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;s&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&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;Same date → same seed → same board, everywhere. No coordination, no database, no clock skew&lt;br&gt;
argument. The scan window exists because not every seed produces a board in the difficulty band&lt;br&gt;
I want, and the fallbacks guarantee the function is total: there is always a puzzle for every&lt;br&gt;
date, even a hypothetical pathological one.&lt;/p&gt;
&lt;h2&gt;
  
  
  "Solvable" has to mean something specific
&lt;/h2&gt;

&lt;p&gt;A generator that emits a valid board isn't enough. Two extra gates run on every candidate:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uniqueness by pure deduction.&lt;/strong&gt; A &lt;code&gt;deductiveSolve&lt;/code&gt; pass tries to solve the board using only&lt;br&gt;
inference rules, up to a maximum technique tier. If it can't finish without guessing, the board&lt;br&gt;
is rejected. This is what lets me promise "never guess" — it's not a claim about the puzzle&lt;br&gt;
looking fair, it's a property checked on every board before it ships.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An aesthetics pass.&lt;/strong&gt; Boards can be functionally valid and still unpleasant: too many givens,&lt;br&gt;
a row that gives itself away, clues clumped in one corner. A separate &lt;code&gt;aestheticsOk&lt;/code&gt; check&lt;br&gt;
rejects those. It's the least glamorous part of the generator and the one that most changed how&lt;br&gt;
the game feels.&lt;/p&gt;

&lt;p&gt;The difficulty score that &lt;code&gt;specForDaily&lt;/code&gt; filters on falls out of the solver: it's the tier of&lt;br&gt;
the hardest technique the solver needed. Difficulty is measured, not assigned.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two implementations, one algorithm
&lt;/h2&gt;

&lt;p&gt;The generator is written twice: JavaScript for the website and the video pipeline, Dart for the&lt;br&gt;
Flutter app. That's a duplication I'd normally avoid, but sharing a runtime across an Astro&lt;br&gt;
site, a Remotion renderer and a Flutter app costs more than it saves.&lt;/p&gt;

&lt;p&gt;The risk with two implementations is drift. If the ports disagree by one bit in the PRNG, the&lt;br&gt;
app and the website show different puzzles on the same day — which breaks the one promise the&lt;br&gt;
product makes. So there's a cross-determinism test that pins the Dart side against reference&lt;br&gt;
vectors captured from the JS prototype: the raw &lt;code&gt;mulberry32&lt;/code&gt; output sequence for a known seed,&lt;br&gt;
plus fully generated levels at several sizes and seeds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ mulberry32(12345) matches the JS sequence
✓ generate() reproduces the JS reference levels 6x6 seed 42
✓ generate() reproduces the JS reference levels 6x6 seed 1
✓ generate() reproduces the JS reference levels 8x8 seed 7
✓ generate() reproduces the JS reference levels 6x6 conn seed 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Being honest about the gap: that test lives in the app repo and I run it by hand. It is not yet&lt;br&gt;
wired into CI, which means it protects against drift only when I remember to run it. Writing&lt;br&gt;
this post is what made me go look — and it still passes, but "passes when I remember" is not a&lt;br&gt;
guarantee. That's the next thing I'm fixing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything else is a consumer of the same function
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;website&lt;/strong&gt; (Astro on Cloudflare Pages) prerenders one indexable URL per date, in three
languages, and rebuilds itself daily in CI so "today" rolls over on its own.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;video pipeline&lt;/strong&gt; (Remotion) renders a vertical short of the solve animation for each
day, in English, Spanish and Portuguese. It uses a &lt;em&gt;different&lt;/em&gt; seed offset on purpose, so the
video doesn't spoil the actual daily.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Flutter app&lt;/strong&gt; computes the same spec locally, which is why it works fully offline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these talk to each other. They just agree, because they compute the same function of&lt;br&gt;
the same date.&lt;/p&gt;




&lt;p&gt;Play today's puzzle in the browser: &lt;a href="https://solsticio.app/" rel="noopener noreferrer"&gt;https://solsticio.app/&lt;/a&gt;&lt;br&gt;
Free Android app: &lt;a href="https://play.google.com/store/apps/details?id=es.cs.solsticio" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=es.cs.solsticio&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on the solver or the uniqueness check if anyone's interested.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>flutter</category>
      <category>algorithms</category>
    </item>
  </channel>
</rss>
