DEV Community

Cover image for Building Mathastic Twice: One Math Puzzle Game, Two Native Architectures
tapadyuti chatterjee
tapadyuti chatterjee

Posted on

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

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 or connect with me on LinkedIn.

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.

TL;DR

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 idea behind Mathastic

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:

  1. Start with approachable questions.
  2. Build a streak.
  3. Feel the pressure increase.
  4. Decide when to use a hint, skip, or time bonus.
  5. Finish with a useful summary of the run.

That led to several game modes:

  • Timed Sprint is the classic race against the clock.
  • Practice removes the timer and penalties so the player can focus on repetition.
  • Endless increases the pressure as the run progresses.
  • Daily Challenge generates a consistent challenge from the current day.

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.

Starting with a shared domain model

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.

The high-level architecture

Conceptually, Mathastic is divided into four layers:

UI and navigation
        ↓
Game session state
        ↓
Question, mission, and challenge generation
        ↓
Local scores, progress, and preferences
Enter fullscreen mode Exit fullscreen mode

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.

Question generation is more than choosing two numbers

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:

  • Division questions are built from a divisor and quotient, so answers remain whole numbers.
  • Some addition questions hide an operand instead of always hiding the result.
  • Wrong options are generated near the correct answer rather than being completely random.
  • Endless mode can promote the effective difficulty as the run develops.
  • Each question records its operation so the app can calculate per-operation accuracy later.

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.

Deterministic daily challenges

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 does not require a backend to publish each challenge.
  • The challenge remains stable during the day.
  • Scores can be associated with the daily seed.
  • The behavior is easy to reproduce while debugging.

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 implementation

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:

  • Current question
  • Score and streak
  • Remaining time
  • Hint and skip counts
  • Mission progress
  • Operation statistics
  • Tutorial state
  • Final result

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 implementation

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
Enter fullscreen mode Exit fullscreen mode

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
  • A BroadcastReceiver to receive it
  • A notification channel on Android 8 and newer
  • A PendingIntent to reopen the app
  • Runtime notification permission handling on newer Android versions

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

SwiftUI and Compose: similar ideas, different centers of gravity

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.

Native parity does not mean identical code

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.

Technical decisions that paid off

A few decisions had an outsized effect on maintainability.

Keep generation logic outside the UI

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

Model difficulty as behavior

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.

Save raw run data, derive progression

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.

Keep persistence lightweight

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.

Build fairness into generated questions

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.

What I learned

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.

What I would improve next

There are several natural directions for Mathastic:

  • Expand automated tests around question generation, seeded challenges, scoring, and progression
  • Move more iOS session logic into a dedicated game-state object
  • Add stronger persistence migrations as the score model evolves
  • Improve accessibility and adaptive layouts across more device sizes
  • Explore optional cloud sync or shared leaderboards
  • Add deeper analytics that turn weak operations into targeted practice

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.

Closing thoughts

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 or connect with me on LinkedIn.

Top comments (0)