A daily logic puzzle with no backend at all
I built Solsticio, a daily sun/moon logic puzzle in the Binairo/Takuzu family, and I set myself one engineering constraint: no backend. The daily puzzle had to be identical for everyone — on the web, in the Android app, and in the social videos — without a server deciding anything.
The date is the seed
The whole thing rests on a generator that is deterministic given a seed, and a seed derived from the date:
export function specForDaily(y, m, d) {
const base = y * 10000 + m * 100 + d;
// First seed in [base, base+500) that yields a 6x6 board of difficulty 5..7.
for (let s = base; s < base + 500; s++) {
const r = generate({ n: 6, maxTier: TIER.CONTRA1 }, s);
if (r.ok && r.difficulty >= 5 && r.difficulty <= 7) return { seed: s, n: 6 };
}
// Fallbacks: first solvable board, then the base seed itself.
for (let s = base; s < base + 500; s++) {
if (generate({ n: 6 }, s).ok) return { seed: s, n: 6 };
}
return { seed: base, n: 6 };
}
Same date → same seed → same board, everywhere. No coordination, no database, no clock skew argument. The scan window exists because not every seed produces a board in the difficulty band I want, and the fallbacks guarantee the function is total: there is always a puzzle for every date, even a hypothetical pathological one.
“Solvable” has to mean something specific
A generator that emits a valid board isn’t enough. Two extra gates run on every candidate:
Uniqueness by pure deduction. A deductiveSolve pass tries to solve the board using only
inference rules, up to a maximum technique tier. If it can’t finish without guessing, the board
is rejected. This is what lets me promise “never guess” — it’s not a claim about the puzzle
looking fair, it’s a property checked on every board before it ships.
An aesthetics pass. Boards can be functionally valid and still unpleasant: too many givens,
a row that gives itself away, clues clumped in one corner. A separate aestheticsOk check
rejects those. It’s the least glamorous part of the generator and the one that most changed how
the game feels.
The difficulty score that specForDaily filters on falls out of the solver: it’s the tier of
the hardest technique the solver needed. Difficulty is measured, not assigned.
Two implementations, one algorithm
The generator is written twice: JavaScript for the website and the video pipeline, Dart for the Flutter app. That’s a duplication I’d normally avoid, but sharing a runtime across an Astro site, a Remotion renderer and a Flutter app costs more than it saves.
The risk with two implementations is drift. If the ports disagree by one bit in the PRNG, the
app and the website show different puzzles on the same day — which breaks the one promise the
product makes. So there’s a cross-determinism test that pins the Dart side against reference
vectors captured from the JS prototype: the raw mulberry32 output sequence for a known seed,
plus fully generated levels at several sizes and seeds.
✓ 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
Being honest about the gap: that test lives in the app repo and I run it by hand. It is not yet wired into CI, which means it protects against drift only when I remember to run it. Writing this post is what made me go look — and it still passes, but “passes when I remember” is not a guarantee. That’s the next thing I’m fixing.
Everything else is a consumer of the same function
- The website (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.
- The video pipeline (Remotion) renders a vertical short of the solve animation for each day, in English, Spanish and Portuguese. It uses a different seed offset on purpose, so the video doesn’t spoil the actual daily.
- The Flutter app computes the same spec locally, which is why it works fully offline.
None of these talk to each other. They just agree, because they compute the same function of the same date.
Play today’s puzzle in the browser: https://solsticio.app/ Free Android app: https://play.google.com/store/apps/details?id=es.cs.solsticio
Happy to go deeper on the solver or the uniqueness check if anyone’s interested.