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

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: - Start with approachable questions. - Build a streak. - Feel the pressure increase. - Decide when to use a hint, skip, or time bonus. - 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 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 , 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 - 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 | |---|---|---|

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.