Skip to content

Repository files navigation

RECKON

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 rule that governs this repository

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.

Never add the web platform

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.


Layout

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.

How a day flows

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.


How generation works

  1. Draw six Countdown-legal tiles — larges without replacement from {25, 50, 75, 100}, smalls from a double deck of 1..10.
  2. 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.
  3. 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.


Streaks

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.


Commands

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.dart

Run the suite under a DST timezone so the day-index tests exercise real transitions:

TZ=America/New_York flutter test --exclude-tags sweep

The 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.

About

Daily number-target puzzle with ZERO backend — the puzzle is a pure function of the date, so every player worldwide gets byte-identical boards. Determinism verified by a 3-year forward sweep in CI. Flutter, 164 tests.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages