DEV Community

Binshad Basheer
Binshad Basheer

Posted on

How I built a Wordle-style daily puzzle in SwiftUI (no backend)

I recently shipped Gemdoku: Daily Block Puzzle, a 9×9 block puzzle for iPhone. The core feature is a Wordle-style Daily Challenge: every player in the world gets the same goal, the same move limit and the exact same sequence of pieces each day.

It turned out to be a fun little engineering problem. Here's how it works under the hood, all in plain Swift and SwiftUI, with no backend.

1. One puzzle per day, with no server

The obvious way to give everyone the same puzzle is to generate it on a server. I didn't want a backend. The game is fully offline, has no accounts and collects no data. So the daily puzzle has to be derived, not downloaded.

The trick is a seeded random number generator. Swift's RandomNumberGenerator protocol makes this easy. I use SplitMix64, which is tiny, fast and good enough for games:

struct GameRNG: RandomNumberGenerator {
    private var state: UInt64?
    private var system = SystemRandomNumberGenerator()

    init(seed: UInt64? = nil) { state = seed }

    mutating func next() -> UInt64 {
        guard var z = state else { return system.next() }  // unseeded = Classic mode
        z &+= 0x9E3779B97F4A7C15
        state = z
        z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9
        z = (z ^ (z >> 27)) &* 0x94D049BB133111EB
        return z ^ (z >> 31)
    }
}
Enter fullscreen mode Exit fullscreen mode

The same type serves both modes. With no seed it falls back to the system RNG for Classic mode. With a seed it is fully deterministic. Because everything takes inout GameRNG, the whole piece library uses the standard APIs (randomElement(using:), Int.random(in:using:), shuffle(using:)) without knowing which mode it's in.

The seed is just the date:

static func dailySeed(for date: Date, calendar: Calendar = .daily) -> UInt64 {
    let c = calendar.dateComponents([.year, .month, .day], from: date)
    return UInt64((c.year ?? 0) * 10_000 + (c.month ?? 0) * 100 + (c.day ?? 0))
}
// 2026-11-10 → 20261110
Enter fullscreen mode Exit fullscreen mode

2. The time zone bug you will definitely hit

My first version used Calendar.current. That meant a player in Manchester and a player in Sydney were on different puzzles for most of the day, which broke both the "same puzzle for everyone" promise and the daily leaderboard.

The fix is a dedicated UTC calendar that everything daily-related goes through:

extension Calendar {
    /// Rolls over at 00:00 UTC, so every player worldwide is on the same puzzle.
    static let daily: Calendar = {
        var calendar = Calendar(identifier: .gregorian)
        calendar.timeZone = TimeZone(identifier: "UTC")!
        return calendar
    }()
}
Enter fullscreen mode Exit fullscreen mode

The Game Center "fewest moves" leaderboard is a recurring leaderboard that also resets at 00:00 UTC, so the two always line up.

3. Don't let one RNG disturb another

Each day needs more than a piece sequence. It also needs a goal (collect 5 rubies, clear 6 lines, chain a ×4 combo…), a move limit and, on harder days, a starting layout.

If you pull all of those from the same RNG, any change to how goals are picked silently shifts every piece that follows. So the goal gets its own stream, derived by XOR-ing the day's seed with a constant:

var rng = GameRNG(seed: seed ^ 0xD411_C4A1_1E46_E5EE)
let goal = goals.randomElement(using: &rng)!
Enter fullscreen mode Exit fullscreen mode

The piece sequence keeps the raw seed, so I can tune goals freely without changing anyone's pieces.

4. Difficulty that builds through the week

Difficulty comes from the weekday:

let difficulty: Difficulty = switch calendar.component(.weekday, from: date) {
case 2, 3: .easy      // Mon, Tue
case 4, 5: .medium    // Wed, Thu
default:   .hard      // Fri–Sun
}
Enter fullscreen mode Exit fullscreen mode

Harder days start with blocks already on the board. A random scatter looked like noise, so I mirror each block four ways, which makes the layout read like a designed puzzle:

for (mr, mc) in [(r, c), (r, last - c), (last - r, c), (last - r, last - c)] {
    cells.insert(GridPosition(row: mr, col: mc))
}
Enter fullscreen mode Exit fullscreen mode

One gotcha: Set iteration order isn't stable between runs, so before picking which cells get gems I sort the set. Otherwise two players could get different layouts from the same seed.

5. Making sure every goal is actually beatable

A seeded goal like "collect 8 emeralds" is useless if that day's piece sequence only deals five. Because the sequence is deterministic, the game can simply look ahead. It replays the day's trays, counts the gems that will actually appear within the move limit, and caps the target at a share of them:

let available = startingGems + gemsDealt(gem, seed: seed, moves: moveLimit)
let reachable = Int(Double(available) * [0.8, 0.9, 1.0][tier])
goal = .collectGems(gem, min(target, max(2, reachable)))
Enter fullscreen mode Exit fullscreen mode

For the other goal types, I tuned targets by simulating a greedy, goal-unaware player over a full year of seeded sequences. Easy targets sit around that player's 25th percentile, medium around the median, and hard above the 75th. So they're beatable if you play for the goal.

6. Stars, streaks and sharing

Winning earns 1–3 stars based on the share of the move budget used. The thresholds loosen on harder days, so about half of wins earn three stars.

The share sheet sends a SwiftUI view rendered with ImageRenderer. It shows your stars, score and board but never the solution, plus a text line that includes the App Store link. That share card does most of the marketing.

What I'd tell anyone building a daily game

  • Derive, don't download. A date seed plus a deterministic RNG gives you a global daily puzzle with zero infrastructure.
  • Pick one time zone and use it everywhere, including your leaderboards.
  • Give each concern its own RNG stream so tuning one thing doesn't reshuffle everything.
  • Validate generated goals against the actual sequence. Determinism means you can check them in advance.
  • Simulate to tune difficulty. Guessing targets by hand was hopeless.

If you'd like to try today's puzzle, it's free with no ads, no account and no tracking: Gemdoku: Daily Block Puzzle on the App Store.

How many moves did it take you? I'd love feedback on the difficulty curve. 💎

Top comments (1)

Collapse
 
wordforge_jp profile image
WordForge •

This is a really clear breakdown — thank you for sharing the details. The separate RNG streams idea stood out to me: tuning goals without reshuffling the piece sequence is exactly the kind of invisible design decision that saves a lot of frustration later. The UTC fix is also a great catch; it is easy to assume a "daily" puzzle is the same for everyone until time zones prove otherwise. Validating goals against the actual dealt sequence, rather than trusting the generator, feels like the most important lesson here. Do you find the weekday difficulty ramp changes how often players return later in the week?