Six numbers. One target. Once a day.
A daily number-target puzzle for iOS and Android. Three escalating rounds — Warm-up, Challenge, Boss — generated on-device from the calendar date, so every player in the world gets the identical puzzles. No server, no accounts, no ads, no network calls of any kind.
Status: feature complete, unsigned. The full daily loop plays end to end — three rounds, stars, streaks with freeze tokens, mid-round resume, share card, stats, evening reminders. 164 tests pass, the analyzer is clean, and the three-year sweep is green. What remains is signing and store submission; see RELEASE.md.
The puzzle for any given day is a pure function of the date. There is no server. That means:
If two app versions generate different puzzles for the same day, the product is broken — permanently, retroactively, and with no way to fix it.
Players on the old build and players on the new build would see different boards, and every share card would stop comparing like for like. Nobody would even notice until the complaints started, because both builds work perfectly in isolation.
Three files are therefore frozen:
| File | Why |
|---|---|
lib/engine/prng.dart |
Draw sequence. Changing a constant re-rolls all of history. |
lib/engine/generator.dart |
Draw order and acceptance rules. Reordering two PRNG calls is enough to change every puzzle. |
lib/engine/engine_version.dart |
The deliberate escape hatch. |
Reading them is fine. Refactoring them is not, unless you have decided as a product matter to
change everyone's puzzles — in which case bump kEngineVersion and regenerate the goldens in
one deliberate commit.
The golden tests in test/engine/ exist to catch an accidental change. If a golden test
fails, do not update the expected values. Find what moved and revert it.
lib/engine/prng.dart relies on Dart's native int being 64-bit two's complement with
wrapping arithmetic. On the web, int is a double: every line of the mixer silently produces
different numbers, with no error. Mobile only.
lib/
engine/ prng · generator · solver · puzzle · engine_version ← pure Dart, no Flutter imports
core/ day_index · streak · haptics · theme/tokens
data/ local_store · progress · puzzle · snapshot · settings repositories
share_service · notification_service
features/ home/ · game/ · session/ · results/ · stats/ · settings/
test/
engine/ prng · solver · generator (goldens + invariants + sweep)
core/ day_index · streak
data/ progress_repository · share_service
features/ game_cubit · session_cubit · board_render · full_day (end to end)
tool/
dump.dart prints puzzles, timings and golden values
preview_main.dart renders a board state on demand, for screenshots
preview_results.dart renders the day summary with a seeded streak
The engine/ directory imports nothing from Flutter. That keeps it fast to test and makes it
impossible for puzzle generation to accidentally depend on UI state.
SessionCubit owns the calendar day; GameCubit owns one round and knows nothing about
streaks or storage. A round reports upward when it closes, and the session commits the day
to permanent record exactly once.
Two design decisions worth knowing before changing anything:
The board is derived from the move list, not stored. GameState holds List<Move>, and
every tile is rebuilt by replaying those moves against the puzzle. Tile ids are positional
(opening tiles take 0..5; the result of move k takes 6+k), so a replay is byte-identical
to the original session. Undo is therefore exact rather than approximate, and resuming a
half-finished round costs a few hundred bytes instead of a serialised board.
Operand order is forgiving. Tapping 3, −, 100 performs 100 − 3. Nobody taps an
impossible move on purpose, so if the reverse is legal the game does the reverse. Only moves
illegal in both directions are refused, and every refusal says why in plain language while
keeping the tile selected.
- Draw six Countdown-legal tiles — larges without replacement from
{25, 50, 75, 100}, smalls from a double deck of1..10. - Construct the target by applying real operations to those tiles. Solvability is guaranteed by the method, not by hoping a random 3-digit number is reachable.
- Verify with an exhaustive solver, and accept only if par and route count fall inside the round's difficulty band.
Step 3 is the one that matters for feel. Construction proves a puzzle can be solved; it does nothing to stop a five-move construction from having an accidental two-move solution. Gating on par and on how many distinct routes reach the answer is what makes difficulty consistent day to day — a Boss round with one route produces the "aha", one with three hundred is just arithmetic.
Search is over board multisets rather than expressions (25+3 and 3+25 are the same
board), which collapses the raw ~33M expression space to tens of thousands of states. Because
every operation consumes two tiles and produces one, a state's size determines exactly how
many moves reached it — so the memo needs no depth bookkeeping and each state is visited once.
Generating a full day takes roughly 60–320ms on an M-series Mac. It runs in an isolate and is cached per day, so a player pays it once.
lib/core/streak.dart holds the rules as pure functions, so they are testable without a
store or a clock.
reconcile(progress, today)runs on launch and at every midnight rollover. A gap covered by banked freeze tokens consumes them and preserves the streak; an uncovered gap resets the streak but keeps the tokens — losing both at once is the moment people delete the app.applyCompletion(progress, today)is idempotent, and earns a freeze every seventh day up to a cap of two.
Day numbering reads local calendar date components and re-expresses them in UTC, which has no
daylight saving. A naive difference().inDays rounds the wrong way on 23- and 25-hour local
days and silently duplicates or skips a puzzle number — which kills streaks twice a year.
flutter test --exclude-tags sweep # the everyday suite, ~30s
flutter test --tags sweep # three-year sweep, ~3min — RUN BEFORE EVERY RELEASE
dart run tool/dump.dart 1 100 365 # inspect specific days
flutter run # play today's puzzle on a device
# Screenshot harnesses (0 = fresh board, 1 = mid-round, 2 = solved)
flutter run -t tool/preview_main.dart --dart-define=STATE=2
flutter run -t tool/preview_results.dartRun the suite under a DST timezone so the day-index tests exercise real transitions:
TZ=America/New_York flutter test --exclude-tags sweepThe sweep asserts that all 1,095 days of the next three years generate a valid, solvable, Countdown-legal board whose stored solution actually reaches its target. It is the gate that proves the generator's retry loop never runs dry on a date nobody has reached yet.