# Building Mathastic Twice: One Math Puzzle Game, Two Native Architectures

> Source: <https://dev.to/tapadyutichatterjee/building-mathastic-twice-one-math-puzzle-game-two-native-architectures-1og6>
> Published: 2026-08-15 23:29:23+00:00

I recently released **Mathastic**, a fast-paced math puzzle game for iOS and Android.

I’m Tapadyuti Chatterjee, a software engineer interested in distributed systems, mobile development, and practical applications of AI. You can learn more about my work on [my personal website](https://tapadyuti.com/) or connect with me on [LinkedIn](https://www.linkedin.com/in/tapadyutichatterjee/).

This is also my first post on DEV, so I wanted to go beyond a simple launch announcement. Instead, I want to share how the app works, how I structured the two native codebases, and what I learned while translating the same product into SwiftUI and Jetpack Compose.

Mathastic is a native iOS and Android game that turns arithmetic practice into short, replayable runs. Players choose a game mode, difficulty, and operation mix, then build streaks, complete missions, earn XP, unlock themes, and track their performance over time.

The iOS version uses SwiftUI, observable state, and `UserDefaults`

. The Android version uses Jetpack Compose, a `ViewModel`

with `StateFlow`

, Hilt for dependency injection, and `SharedPreferences`

with Gson.

Both apps share the same product rules and domain concepts, but each follows the conventions of its platform instead of forcing an identical implementation.

*Transparency note: I used AI to help format and polish the wording of this article. The app, architecture, implementation decisions, and experiences described here are my own.*

The original idea was simple: arithmetic practice should feel less like a worksheet and more like a game you want to replay.

A basic math quiz can ask a question, accept an answer, and display a score. That works, but it does not create much momentum. For Mathastic, I wanted each session to have a small emotional arc:

That led to several game modes:

Players can focus on addition, subtraction, multiplication, division, or a mixed set. Difficulty changes more than operand size: it also affects the timer, scoring, penalties, streak bonuses, and XP.

The surrounding progression system—missions, achievements, levels, unlockable themes, score history, and operation-level accuracy—exists to give players a reason to return without getting in the way of the central activity.

Although the iOS and Android projects are separate native applications, I kept their domain language deliberately similar.

Both versions have equivalents of:

`GameConfiguration`

`MathQuestion`

`Score`

`GameResult`

`GameMission`

`MissionProgress`

`PlayerProfile`

`OperationStat`

`DailyChallenge`

Enumerations represent the major rule choices: difficulty, operation, game mode, theme, and mission type.

This was one of the most useful architectural decisions in the project. When the product has a stable vocabulary, platform-specific code becomes easier to reason about. “Timed Sprint” should mean the same thing whether its state is stored in a Swift property wrapper or a Kotlin `StateFlow`

.

The UI implementations can differ. The game rules should not.

Conceptually, Mathastic is divided into four layers:

```
UI and navigation
        ↓
Game session state
        ↓
Question, mission, and challenge generation
        ↓
Local scores, progress, and preferences
```

The UI renders the current state and sends player actions such as answering, requesting a hint, skipping a question, or ending a run.

The game-state layer applies the rules: scoring, streaks, timing, progression, and mission updates.

Small factory components generate questions, daily challenges, and missions.

Finally, a local score store persists completed runs and derives higher-level information such as XP, levels, achievements, best scores, and operation accuracy.

I chose a local-first design. Mathastic does not require an account or server round trip to begin a game. For this kind of app, immediate startup and offline play are more valuable than introducing a backend before it is necessary.

The question factory is one of the most important pieces of the app.

It selects an operation, chooses operands based on difficulty, calculates the correct result, and creates three plausible wrong answers. Mixed mode resolves to a specific operation for every question.

A few details improve the experience:

This logic is isolated from the visual layer. A screen should not need to know how to construct a valid division problem or produce convincing distractors. It only needs a `MathQuestion`

containing a prompt, a correct answer, and a set of options.

The Daily Challenge created an interesting requirement: randomness needed to be predictable.

A normal run can generate a fresh sequence. A daily challenge should be tied to the day so that different sessions receive the same underlying challenge configuration.

Both apps derive a numeric seed from the current date. That seed determines the day’s difficulty, operation mix, theme, and question sequence.

This approach has several advantages:

It was a good reminder that “random” and “uncontrolled” are not the same thing. Seeded randomness preserves variety while still giving the system repeatable behavior.

The iOS app is written with **SwiftUI**.

A `NavigationStack`

begins at the welcome screen and moves into the active game configuration. Shared progress is held by a `ScoreStore`

created as a `StateObject`

at the app level and passed through the SwiftUI environment.

The game screen uses SwiftUI state for the active session:

Persistent player settings use `@AppStorage`

, while completed scores are encoded and stored through `UserDefaults`

. The score store publishes a derived snapshot containing the player profile, achievements, and operation insights.

This creates a straightforward flow: changing game state causes SwiftUI to redraw the relevant parts of the interface, while saving a completed run rebuilds the player’s longer-term progress.

For reminders, iOS uses `UNUserNotificationCenter`

with a repeating calendar trigger. The app asks for notification permission only when the player chooses to enable the reminder, which was important to me. A reminder should be an opt-in convenience, not an automatic interruption.

The Android app uses **Kotlin**, **Jetpack Compose**, **Material 3**, and **Navigation Compose**.

The main architectural difference is that the active game logic lives in a `GameViewModel`

. The view model exposes an immutable `StateFlow<GameUiState>`

, and Compose collects that state to render the game.

Player actions call methods on the view model:

```
Player action
    → ViewModel updates GameUiState
    → StateFlow emits a new value
    → Compose recomposes the UI
```

The timer is implemented as a coroutine job inside the view model, which makes cancellation and lifecycle handling more explicit than keeping timer behavior inside a composable.

Android also uses Hilt for dependency injection. The question factory, mission factory, daily challenge factory, and score store are provided to the components that need them. `SharedPreferences`

and Gson provide lightweight local persistence for scores and settings.

Daily reminders require more platform plumbing on Android. The implementation uses:

`AlarmManager`

to schedule the repeating event`BroadcastReceiver`

to receive it`PendingIntent`

to reopen the appThe end result looks similar to the user, but the route to that result is distinctly Android.

SwiftUI and Jetpack Compose feel philosophically related. Both encourage declarative interfaces where the UI is a function of state.

The differences become clearer once the app grows beyond a few screens.

On iOS, SwiftUI property wrappers make it natural to keep a moderate amount of session state close to the view. Shared progress fits neatly into an observable environment object.

On Android, the `ViewModel`

and `StateFlow`

combination creates a stronger separation between rendering and game logic. Compose primarily observes state and forwards events.

Neither structure is automatically better. The important question is whether state has a clear owner.

If I continued expanding the iOS version, I would likely move more of the active game-session logic into a dedicated observable model. The Android version already has that boundary because its view model owns the session.

This is one of the advantages of building the same idea twice: each platform reveals architectural improvements that can inform the other.

My goal was feature parity, not line-by-line parity.

The two apps share concepts and behavior, but the implementations use native platform tools:

| Concern | iOS | Android |
|---|---|---|
| UI | SwiftUI | Jetpack Compose |
| Reactive state | SwiftUI state and `ObservableObject`
|
`ViewModel` and `StateFlow`
|
| Navigation | `NavigationStack` |
Navigation Compose |
| Preferences |
`@AppStorage` and `UserDefaults`
|
`SharedPreferences` |
| Score serialization | `Codable` |
Gson |
| Dependency management | App-level environment object and direct factories | Hilt |
| Timers | Foundation `Timer`
|
Coroutines |
| Reminders | User Notifications framework | AlarmManager, receiver, and notification channel |
| Visual language | SF Symbols and SwiftUI styling | Material icons and Material 3 |

Trying to hide all these differences behind a rigid cross-platform shape would have made both implementations less natural.

Instead, I treated the domain model and game behavior as the contract. Everything around that contract was allowed to follow platform conventions.

A few decisions had an outsized effect on maintainability.

Question, mission, and daily challenge generation live in dedicated factories. This keeps the screens focused on presentation and interaction.

Difficulty is not just a label. Each difficulty owns values such as time limit, operand range, correct-answer points, wrong-answer penalty, streak bonus, and XP multiplier.

That makes balancing changes easier and avoids scattering conditionals throughout the app.

Each completed score stores useful facts about the run: accuracy inputs, streak, XP, completed missions, game mode, difficulty, theme, daily seed, and operation statistics.

The score store then derives the profile, achievements, unlocked themes, and analytics. This is more flexible than persisting every calculated label separately.

The data currently fits comfortably in local preferences as encoded JSON. Adding a database would create migration and query infrastructure without yet providing enough value.

That choice may change if the app gains cloud sync, social leaderboards, or a much larger history. Architecture should reflect current needs while leaving room for the next likely step.

Clean division answers, plausible distractors, controlled operand ranges, and reproducible daily challenges are small implementation details with a large effect on player trust.

A math game can be visually polished and still feel wrong if its question generator is careless.

The hardest part of building the app twice was not translating Swift into Kotlin. It was preserving the same experience across two different ecosystems.

A few lessons stood out.

First, **write down the game rules as data**. When scoring and difficulty values are centralized, the two versions are much easier to compare.

Second, **state ownership matters more than framework syntax**. Declarative UI is pleasant only when it is clear which component controls the timer, score, current question, and final result.

Third, **small platform features can require very different implementations**. A “daily reminder” is one checkbox in the interface, but underneath it involves different permissions, scheduling systems, lifecycle rules, and APIs.

Fourth, **offline-first was the right constraint for this version**. Avoiding accounts and network dependencies kept the core loop fast and let me spend more time on the actual game.

Finally, **parity needs a checklist**. It is easy to add a scoring adjustment, mission, or tutorial improvement on one platform and forget the other. Shared terminology helps, but explicit feature and rule comparisons are even better.

There are several natural directions for Mathastic:

The key word is *optional*. I still want the app to open quickly and let someone solve a math problem without creating an account or waiting for a server.

Mathastic began as a small math puzzle idea, but building it natively for two platforms turned it into a useful architecture exercise.

The project taught me that a shared product does not require a shared UI framework. With a clear domain model, deterministic rules, and well-defined state ownership, two native implementations can feel like the same app while still respecting their platforms.

If you try Mathastic, I would love to hear which mode you play and where the experience could improve:

Thanks for reading my first DEV post! You can find more of my projects and writing at [tapadyuti.com](https://tapadyuti.com/) or connect with me on [LinkedIn](https://www.linkedin.com/in/tapadyutichatterjee/).
