Why a daily puzzle ID needs a generator version

Jaemyeong Jin···10 min read
한국어

The board of a daily puzzle is made from the date. That is how the Sudoku app DailySudoku works. With the same day integer and the same core build, entering through the normal daily screen gives the same board on iOS, Android, and the web. But if the way boards are generated changes, the board can differ even when the seed stays the same. When the keys for storage, analytics, and leaderboards hold only the date, that difference is hard to express.

The baseline is commit 9c99930582be23d2c72d37fb46d77a488b9fe04c on the develop branch, dated August 6, 2026. I describe the current structure and the proposal on top of it separately. The DailyPuzzleIdentity type, the device-local-v1 day policy, the g1 and g2 IDs, schema v2, and the resolver are all proposals and are not implemented yet.

How a daily puzzle is created today

Entering the daily puzzle screen decides the board in this order.

현재 시각
  -> 기기 로컬 시간대의 epochDay
  -> dailySeed(epochDay)
  -> generate(MEDIUM, seed)
  -> puzzleId = "DAILY-<epochDay>"

The first two lines read “current time” and “epochDay in the device’s local time zone.”

dailySeed uses the SplitMix64 finalizer and is computed with 64-bit wrapping addition, wrapping multiplication, and logical shifts. The generator builds a complete board with its own RNG and then removes only the clues that keep the solution unique. This core is written in Rust. iOS and Android call it through UniFFI, and the web calls it through WebAssembly.

From the normal daily entry point, with the same day integer and the same core build, the fresh board and the solution match on all three platforms. That determinism and the golden fixtures are explained in the post on testing and CI for the shared core.

The date is the device’s local date

The day integer comes from the device’s local date, not the UTC date.

localEpochDay = floor((utcMillis + offsetAtInstant) / 86_400_000)

The offset is “local minus UTC” at that instant. The one that returns milliseconds is TimeZone.getOffset on Android. secondsFromGMT(for:) on iOS returns seconds, so it is converted to milliseconds. getTimezoneOffset on the web returns “UTC minus local” in minutes, so the sign is flipped and the value is multiplied by 60_000. Because of daylight saving time, the offset must not be a constant.

Under this policy, the day integer can differ between Seoul and Los Angeles at the same UTC instant. A time zone change after travel affects the ID on the next launch. A game that has already started keeps its date, and the date is decided again the next time the daily puzzle is entered. There is no guarantee that the whole world gets one board at the same moment. Conversely, devices that pick the same day integer, such as ones in Seoul and Tokyo, get the same board when the core and the difficulty are the same. So the time zone ID can be left out of the identity fields.

What a date-only ID misses

DAILY-<epochDay> has neither the difficulty nor the generator version. The version: 1 in the JSON that Rust stores is the number of the serialization format. The reader rejects any other schema that has no migration. That number is not the version of the generation algorithm.

There is a case where generation really changed. For the Expert difficulty, the maximum number of attempts was raised from 8 to 4,096, and for seed 159 a 24-clue board came out in place of the earlier 25-clue fallback. This is the change history of Expert, and it is not evidence that MEDIUM, which the daily puzzle uses, changed. It should be read only as a case showing that generation rules can change.

If the generator changes while old and new apps run together, this happens. A new game in the old app is a board made by g1. A new game in the new app is a board made by g2. A restored game is the stored g1 board. All three can use the same DAILY-day. When the storage format is compatible, the old game can continue by restoring the board and the solution, but the collision of the external ID in analytics remains. Standard Game Center and Play Games have no feature that splits scores by a dynamic puzzle ID. Which version’s scores to accept has to be decided before submission. The limits of context and the score tag are covered in the post on leaderboard date boundaries.

The proposed identity

I propose a type that groups the day policy, the day integer, the difficulty, and the generator version.

type DailyPuzzleIdentity = {
  dayPolicy: "device-local-v1";
  epochDay: number;
  difficulty: "MEDIUM";
  generatorVersion: number;
};

Each field has its own responsibility. dayPolicy covers the boundary of a day, epochDay the chosen date, difficulty the generation target, and generatorVersion the seed derivation, the RNG, and the generation rules. With dayPolicy in place, changing the date rule later does not apply the new rule to old identities. Metadata that records the observed time zone and dayPolicy have different roles.

Written as a string ID, the identity looks like this.

DAILY-device-local-v1-20671-MEDIUM-g1

The seed can be computed again from epochDay, so it does not need to be in the ID. It can still be used in stored data or telemetry as a helper value for diagnosis and fixtures.

generatorVersion is kept separate from the Cargo package version. A change to the UI or an ad SDK alone does not need a bump. A change to the RNG, the shuffle, the difficulty target, clue removal, or retries bumps it depending on whether results are affected. The meaning of a version that has been issued is never changed. If Rust returns the identity and the board together as one result, a g2 board cannot end up with a g1 label.

A unique solution and a stable identity are different questions

Each time the generator removes a clue, the solver looks for up to two solutions, and only a removal that leaves exactly one solution is accepted. This is a check on a freshly generated board. Whether an ID such as DAILY-20671 points to exactly one board is a separate matter.

solver invariant
  -> 이 board에는 해답이 정확히 하나인가

identity invariant
  -> 이 PuzzleIdentity는 배포·복원·분석 전체에서 같은 board를 뜻하는가

The first question asks whether this board has exactly one solution. The second asks whether this PuzzleIdentity means the same board across release, restore, and analytics.

The solver cannot catch two different uniquely solvable boards sharing an ID, and it cannot catch one board having several IDs.

Fixtures have limits too. A fixture only tells whether the result changed for the chosen inputs. The mistake of changing generation rules without bumping the version cannot be blocked automatically by the return API alone. If whole-board equality has to be guaranteed, a canonical digest or an archive fills the gap. A hash only says that something changed. It does not say why, or which generator to use to build the board again. So the version contract of the identity comes first, and whether to add a checksum is decided after that.

Moving stored data

For schema v2, I propose storing the snapshot together with the versioned identity. Existing v1 saves are labeled g1 only when the release history confirms that all of v1 was g1.

A save whose origin is unknown is isolated as legacy-<digest>. The digest is a canonical digest of the givens, the solution, and the difficulty. The entries, the history, and the elapsed time are left out. Such a save is excluded from version comparison. A past record that lacks a board or a version stays as legacy-unknown.

A new game uses the active version, and regenerating a past date uses the recorded version.

PuzzleIdentity.generatorVersion == 1 -> g1 또는 저장된 g1 board
PuzzleIdentity.generatorVersion == 2 -> g2 또는 저장된 g2 board

On each line, the right side reads “that generator, or the stored board of that version.”

A past version that is not supported is reported as an error and is not quietly built with another version. A product with no replay of past games only needs the board and solution of the save in progress and a path that carries the identity. When there is replay or server-side verification, the old generator or an immutable archive is needed.

What to keep when rolling back

If g2 has a problem and is rolled back, the version-aware binary stays, and only new issuance moves from g2 to g1. A board already assigned as g2 stays g2.

The order is fixed as well. First comes a compatible build that reads both v1 and v2 and uses versioned IDs but issues only g1. After that, g2 is turned on by a build or a remote policy. During the rollback period, both g1 generation and reading of g2 snapshots are kept. Going back to an old binary that does not know versions is not allowed.

This switch cannot be forced at once in the current structure. The generator is inside the client binary, and nothing central picks the version. Even if the server or the web is rolled back, a g2 mobile app that is already installed keeps running g2. Versioning cannot force one board per day in a mixed rollout. If that is required, a single authority has to manage an immutable mapping from the date to the generator version and also operate the handling of old clients. That means the server issues the identity or the board, or the minimum app version is controlled.

In a structure that ships both generators and picks one through the server or Remote Config, new issuance can be switched to g1. A stored g2 game finishes with its original identity and board. Excluding g2 scores has to happen before submission, and querying by version needs a custom backend or leaderboards that were split in advance.

Checks that exist now and checks to add

Today the parity fixture pins the seed, the givens, and the solution for the combination of 4 epoch days and 5 difficulties. Overwriting the existing fixture with new results erases the contract of the old version. The fixed 64-bit seed arithmetic is checked with golden values, drift in fresh boards with the Rust tests and parity, and uniqueness with clue removal and tests on selected seeds. Checking 4 by 5 combinations does not prove every input.

For the switch, I propose leaving the g1 fixture as it is and creating the g2 fixture as a new file.

g1 fixture: 변경 금지
  (device-local-v1, day-before-cutover, MEDIUM, g1) -> board A

g2 fixture: 새 파일
  (device-local-v1, cutover-day, MEDIUM, g2) -> board B

resolver test
  저장된 g1 identity -> g1 또는 저장 snapshot
  새 g2 identity     -> g2

The Korean notes say that the g1 fixture must not be changed and that the g2 fixture is a new file. In the resolver test, a stored g1 identity resolves to g1 or the stored snapshot, and a new g2 identity resolves to g2.

There are four things to confirm. The g1 board and solution match on supported builds. Different versions give different external IDs. After a rollback, g2 is not treated as g1. The versions of the identity and the board are never mixed. On the Swift, Kotlin, and TypeScript side, the algorithm is not copied for testing. The tests cover storing and restoring the ID and passing it to the analytics and score policies.

The order of implementation is combining the Rust return value, keeping the identity in schema v2, replacing the analytics key, checking the version at score submission, and fixed fixtures per version. Until a second generator exists, a small switch is enough for dispatch, and there is no need to build a separate factory or a general migration system first.

Identifying the generator version, the contract for old and new versions living together, and migration and rollback per version are all still unimplemented. The determinism guaranteed today holds only while the input and the code are fixed, and that is different from an identity contract that lasts. The constraint on splitting scores in Game Center and Play Games has to be checked again if the platforms or the configuration change.

광고Coupang Partners

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.