Design a daily sequence-memory game for mobile engineers
Prism is my own small app, which makes it a different kind of system design exercise.
When I write about Slack, PayPal, or Robinhood, the hard part is narrowing a large product into something useful. With Prism, the hard part is the opposite: taking a small app seriously enough to see the real product and engineering decisions inside it.
Prism is a daily sequence-memory game, or what I think of as Daily Sequence Memory. The simple version is: the app plays a sequence of notes and visual tiles, and the player tries to repeat it. The more accurate version is: Prism is a local-first mobile app with deterministic daily challenges, tiered difficulty, audio-visual encoding, offline-tolerant score submission, cloud sync, streaks, achievements, push reminders, feature flags, and a serverless backend for shared truth.
That makes it a good case study for mobile system design. The lesson is not that every small app needs a complex backend. The lesson is that a polished mobile app needs a clear boundary between what must be instant and local, and what can be remote, delayed, retried, or merged later.
Practice quiz: Want to test the ideas first or come back later? Try the Prism mobile design quiz. It covers deterministic daily seeds, local gameplay, sync, score submission, streaks, notifications, and reliability tradeoffs.
This post is part of my System design for mobile engineers series.
Problem statement
Design Prism, a daily sequence-memory mobile app.
The app should let a player open the app, choose a mode, watch a sequence, repeat the sequence, record a score, keep streaks and achievements, share results, and compare against other players on a leaderboard.
The mobile-specific goal is this:
Gameplay should feel instant and reliable even when the network is slow, but shared outcomes should still be validated by the server.
That one sentence drives most of the architecture.
The sequence playback, tap handling, animation, sound, score calculation, and local stats should not depend on a live round trip. A memory game that waits on the network between rounds would feel broken. But the app also needs shared daily challenges, leaderboards, cloud sync, push reminders, feature flags, and profile data. Those parts need a backend.
So I would design Prism around a split:
- Local loop: memory gameplay, audio, animation, immediate feedback, settings, local stats, offline cache.
- Remote truth: daily seed generation, score validation, leaderboards, authenticated sync, push tokens, notification jobs, feature flags.
Requirements
Functional requirements
For the first polished version, I would support:
- A daily challenge that is the same for everyone for a given date, tier, and mode.
- Practice play that works without a daily seed.
- Three tiers of difficulty.
- Classic mode, where a wrong answer ends the game.
- Rush mode, where the player works inside a timed window.
- Audio and visual feedback for every note.
- Local stats: best scores, streaks, unlocks, achievements, mistake patterns, share count, and activity.
- Leaderboards and percentile ranking.
- Score sharing with no sequence spoilers.
- Optional sign-in for cloud sync across devices.
- Push reminders for daily play and near-midnight streak risk.
- Settings for sound, haptics, reduced motion, language, and notification preferences.
Non-functional requirements
The important non-functional requirements are:
- Gameplay should not block on the network once the required local state is available.
- Daily challenges should be deterministic and hard to spoof casually.
- Score submission should tolerate flaky mobile connections.
- Cloud sync should merge user progress without destroying better local progress.
- The client should never own shared leaderboard truth.
- The app should be accessible enough that color is not the only signal.
- Audio and animation should feel responsive on mobile hardware.
- Server code should stay small enough for a solo-built app.
- Tests should cover game logic, stores, services, sync, validation, and architecture boundaries.
Product behavior before architecture
A Prism session looks simple from the user’s side:
- Open the app.
- Choose a tier and mode.
- The app plays a starting sequence.
- The player repeats the sequence.
- If the answer is correct, the sequence grows.
- If the answer is wrong, classic mode ends immediately.
- The app records the result locally.
- If it was a daily challenge, the app submits the score and updates leaderboard context.
- The player can share a spoiler-free result.
The architecture should protect that feeling.
The player should not need to understand seeds, sync, retries, Supabase, or serverless functions. They should just feel that the app is fast, fair, and recoverable.
That means the client should do the tap-by-tap work locally. The server should not decide whether each tap is correct. It should decide shared facts: today’s seed, whether a submitted score is valid enough to store, how a player ranks against others, and what cloud state should be merged.
Core game model
Prism’s core game is a small state machine:
- ready: the game is prepared and waiting to play the next sequence.
- playing: the app is showing the sequence with audio and visual pulses.
- inputting: the player is repeating the sequence.
- complete: the round is over and the app can show score, share, and next actions.
In classic mode, the rule is strict: one wrong input ends the game. There is no partial credit. That matters because it keeps the scoring model simple and makes the client logic easy to reason about.
The tiers add difficulty by increasing the number of possible notes:
- Tier 1, Rainbow: color only, using six combinations.
- Tier 2, Spectrum: color plus shape, using thirty combinations.
- Tier 3, Prism: color plus shape plus fill, using sixty combinations.
This is a nice product and engineering decision. The app can keep one shared combination model, then filter it by tier. The player experiences a smooth difficulty curve, but the code does not need three separate games.
The full combination space is:
- 6 colors
- 5 shapes
- 2 fill states
- 60 total combinations
Each combination maps to both a visual tile and a sound. Shape controls the base pitch, color changes the octave multiplier, and fill changes the timbre. That gives the player more than one memory channel. It also protects accessibility better than a color-only design, because shape alone still carries meaning.
High-level architecture
At a high level, Prism has four layers:
- Mobile client: React Native and Expo app with Expo Router, Zustand stores, local storage, audio, haptics, animation, and screens.
- Client services: API service, daily challenge service, sync service, analytics service, notification service, offline queue, audio service, and feature flag module.
- Serverless API: Vercel functions for daily seeds, score submission, leaderboards, sync, profiles, flags, changelog, and notifications.
- Supabase: Postgres-backed storage for scores, profiles, user sync data, notification logs, and feature flags.
The most important part is what does not happen remotely.
The backend does not stream the full sequence to the client. It does not evaluate every tap. It does not run the game loop. It gives the client a seed, then the client can generate the same sequence locally.
That is the key design move.
Daily seed instead of daily sequence
A naive design would store today’s sequence on the server and fetch it every time the player starts a daily challenge.
That works, but it has downsides:
- The server has to store or compute the full sequence.
- The client depends more directly on network availability.
- Longer games need either a long sequence upfront or repeated server calls.
- The server becomes more involved in gameplay than necessary.
Prism uses a better model: the server returns a deterministic seed.
For a given date and tier, the API generates a seed from a secret-backed hash. The client stores that seed locally and uses a seeded random number generator to produce the note sequence. Same seed, same tier, same generator, same sequence.
This gives the app several benefits:
- The daily challenge is shared across players.
- The server does not need to store the actual sequence.
- The client can continue locally after it has the seed.
- The score submission can include the seed so the server can reject obvious mismatches.
- The game loop stays fast.
The tradeoff is that the client still has enough information to generate the challenge. This is not a bank-grade anti-cheat design. For Prism, that is fine. The goal is casual leaderboard trust, not adversarial esports security.
If I were designing a high-stakes competitive game, I would add stronger server validation, replay proofs, device attestation, or server-side simulation. For Prism, I would keep the lightweight design because it matches the product risk.
State management
Prism uses Zustand instead of Redux. That is the right call for this app.
The stores are small and domain-focused:
- Game store: current tier, sequence, phase, score, user input, seeded RNG state, rush/classic mode, and timeout state.
- User store: streaks, best scores, unlocked tiers, daily completion flags, sound themes, mistake stats, activity log, and share count.
- Settings store: sound, haptics, reduced motion, autoplay, language, notification preferences, and tutorial state.
- Auth store: sign-in state.
- Achievement and medal stores: progress and unlock metadata.
A subtle but important implementation detail is that not everything belongs inside Zustand. Timers and callback registries are kept at module scope instead of stored inside reactive state. That avoids unnecessary re-renders and avoids storing functions or timer handles in state snapshots.
For a mobile game, this matters. The app needs the UI to update when the phase changes or the score changes. It does not need the whole screen tree to care every time an internal callback set changes.
Local persistence model
Prism keeps local progress on the device.
The storage wrapper sits on top of AsyncStorage and adds an in-memory cache. That gives callers a simpler local API while still using the native storage layer underneath.
Local state includes:
- user stats
- best scores by tier and mode
- streak and daily completion dates
- unlocked tiers
- achievements and medals
- sound theme access
- settings
- cached daily seeds
- cached API responses
- offline score submissions
This is the right default for a small productivity or memory app. If the user opens the app on a bad connection, the app should not feel empty. It should be able to show known stats and settings, start practice mode, and use cached daily seeds when available.
The risk with local persistence is stale or corrupt state. Prism handles that with a few practical patterns:
- storage initialization has a fallback path for native-module or test environments
- cached fetches validate data shape and remove bad entries
- old daily seeds can be cleared
- cloud sync failures are non-blocking
- score submissions can be queued for retry
The main product idea is: local state is allowed to be useful, but not allowed to become the shared source of truth.
Score submission and leaderboard truth
A daily score is local first, then remote.
When the game ends, the app records the result locally right away. That lets the user see progress even if the network is unavailable. If the result belongs to a daily challenge, the app submits it to the backend.
The server validates basic facts:
- user id format
- tier
- score range
- date format
- no future date
- expected seed for that date and tier
- mode
Then it writes to a scores table with a uniqueness rule across user, tier, date, and mode. That prevents duplicate daily entries for the same slice. The server can upsert so a better score can replace a previous score where the product allows it.
The leaderboard and percentile APIs query Supabase indexes by tier, date, mode, and score. That keeps reads simple and cheap.
This design is not trying to prove that every tap happened honestly. It is trying to make casual cheating harder while keeping the product simple. That is a reasonable tradeoff for Prism.
The most important trust boundary is this:
The client can calculate gameplay, but the server decides what becomes leaderboard state.
Offline behavior and retry design
Prism has two different offline stories.
The first is gameplay. Practice mode can work locally. A daily challenge can also work locally after the app has cached today’s seed. In both cases, the player can keep playing because the game loop is local.
The second is shared state. Leaderboards, sync, profile updates, and push token registration need the network. These operations should fail softly.
For score submission, Prism uses an offline queue. If a transient network failure happens, the app can persist the submission and retry later. The queue has limits, retry counts, and backoff behavior so it does not grow forever or hammer the API.
That is the right shape for mobile. Users move through elevators, trains, parking garages, and weak Wi-Fi. A network error should not erase a completed game.
The product copy should still be honest. The app can say a score is saved locally or pending submission. It should not claim leaderboard placement until the server confirms it.
Cloud sync and conflict resolution
Cloud sync is optional, but it creates a harder design problem: what happens when two devices both have progress?
Prism uses domain-specific merge rules instead of a generic last-write-wins model for everything.
For stats, the broad rule is best wins:
- higher best score wins
- higher max streak wins
- unlocked tiers are unioned
- achievements are unioned by achievement id
- medals are merged by their identity, with better rank winning where relevant
For settings, local state can win because settings are user preference and the latest device interaction is usually what the user expects.
This is a useful lesson for mobile system design. Sync is not one problem. It is many small product decisions.
A streak is not the same kind of data as a language setting. A best score is not the same kind of data as a push preference. A medal is not the same kind of data as a tutorial flag.
If I were designing this in an interview, I would explicitly name the merge policy per data type. That shows better judgment than saying “we sync it” and moving on.
Streaks, reminders, and habit loops
Prism has streaks because daily memory apps need a return loop.
The streak rule should be simple enough to explain:
- daily completion updates the last played date
- if the previous daily completion was yesterday, the streak increments
- if the gap is more than one day, the streak resets
- dates should be compared using a consistent day boundary, not the device’s accidental local clock behavior
Prism uses UTC date strings in the core logic. That is simple and predictable. It may not match every player’s emotional sense of midnight, but it avoids a lot of timezone complexity for a small app.
Notifications complete the loop. The app can register a push token, store notification preferences, and let a server cron job send reminders. The interesting detail is that reminders should respect completion state. A pre-midnight reminder is useful only if the user has not completed the daily challenge yet.
This is another place where the backend is useful. The phone may be asleep, offline, or uninstalled. The server is a better place to decide who should receive reminder pushes.
Notifications and deep links
The notification system has three jobs:
- Register the device’s push token and preferences.
- Send scheduled reminders from the backend.
- Route notification taps to the right place in the app.
The third job is easy to underestimate. A good reminder does not just open the app. It should take the player close to the intended action, such as daily play or leaderboard context.
The app also needs to handle cold starts. If a user taps a notification while the app is closed, the app should replay that notification response after startup initialization completes.
This is the kind of detail that separates a demo from a polished mobile product.
Feature flags and safe rollout
Prism includes feature flags and app config. For a small app, this might sound unnecessary. I think it is useful if the scope stays small.
Feature flags let the app roll out changes like modes, experiments, or UI behavior without forcing every decision into a full app release. On mobile, that matters because app store releases are slower than web deploys.
The important constraint is to keep flags from becoming a second product database. Flags should control rollout and configuration, not become the core source of gameplay truth.
API shape
The API can stay small:
GET /api/daily-seed: returns the deterministic seed for date and tier.POST /api/submit-score: validates and records a score.GET /api/leaderboard: returns ranked daily scores.GET /api/user-percentile: returns percentile context for a user’s score.GETandPOST /api/sync-data: fetches and merges user data.PATCH /api/profile: updates profile, push token, platform, preferences, and language.GET /api/flags: returns feature flag decisions.POST /api/send-notifications: cron-protected reminder job.
That is enough backend surface for a serious MVP.
I would not add a real-time socket, a custom game server, or a large backend framework unless the product grows into live multiplayer, tournaments, or stronger cheat prevention.
Data model
The main backend tables are simple.
Scores
A score row needs:
- user id
- tier
- score
- date
- seed
- mode, such as classic or rush
- created timestamp
The important constraint is uniqueness by user, tier, date, and mode. That gives the product a clean daily leaderboard shape.
The important indexes are around leaderboard reads: tier, date, mode, and score. The leaderboard path should be cheap because it is a common read after a daily game.
User sync data
Cloud sync can store JSON blobs for:
- stats
- settings
- achievements
- medals
- updated timestamp
This is not always the right choice, but it is reasonable here. The data is user-owned, small, and mostly read or written as a whole sync object. If Prism needed complex querying across all users’ achievements, I would normalize more of it. For this app, JSON blobs keep the backend simple.
Profiles
Profiles hold display name, push token, platform, notification preferences, region or language fields, and account links.
Profiles are not the game. They support identity, notification, and leaderboard presentation.
Notification logs and flags
Notification logs help debug delivery. Feature flag tables help control rollout.
These are supporting systems. They should stay boring.
Client architecture companion
If I were explaining Prism as a client architecture design, I would draw the mobile side like this:
Screens and routes
→ hooks and view models
→ Zustand stores
→ services
→ storage or API
A few rules make the client easier to maintain:
- Screens should render state and handle user intent.
- Stores should own state transitions.
- Services should own side effects such as API calls, storage, audio, notifications, sync, and analytics.
- Utility modules should hold deterministic logic, such as sequence generation, date helpers, validation, combinations, and achievement calculations.
- Native modules should be wrapped so tests and non-native environments can survive.
That separation is why Prism can test game logic, stores, services, and sync behavior without needing a full app runtime for every case.
The client also has mechanical guardrails: architecture validation checks import boundaries, direct AsyncStorage access, raw colors in UI areas, and localization parity. Those checks are not glamorous, but they are exactly the kind of thing that keeps a small app from turning messy over time.
Reliability strategy
Prism’s reliability strategy is practical:
- The game loop is local.
- API calls use timeouts and retry where safe.
- 4xx errors are not retried like network errors.
- Failed score submissions can be queued.
- Sync failures do not block gameplay.
- Storage has a test-safe fallback path.
- Sentry records unexpected failures but filters expected network noise.
- Architecture and localization rules run in CI.
The key product decision is that not every failure has the same severity.
If leaderboard submission fails, the app can retry. If sync fails, the app can keep local progress. If audio does not preload, the game experience is harmed and needs clearer fallback handling. If the game store transitions incorrectly, scoring is broken.
Good reliability design starts by ranking failures by user impact.
Testing strategy
For Prism, I would test at several layers:
- Pure utilities: sequence generation, combinations, date helpers, validation, achievement progress.
- Stores: game state transitions, best score updates, tier unlocks, daily flags, auth state.
- Services: daily seed fetching, API retry behavior, offline queue, sync merge, audio and haptics wrappers.
- Backend validation: score submission, seed verification, sync sanitization, rate limits.
- Architecture checks: no direct storage usage, import boundaries, localization parity, no raw design tokens where they should be centralized.
This is the right mix. A memory game has a lot of deterministic logic, so pure tests are valuable. A mobile app also has native modules, so wrappers and mocks matter.
One thing I especially like in the architecture is the shared merge logic. Client and server sync behavior should not drift. If cloud sync merges stats differently than the client expects, users will eventually see confusing progress resets.
Tradeoffs
Seed-based daily challenges are lightweight, not cheat-proof
Returning a seed is elegant. It keeps gameplay local and daily challenges deterministic. It also means a motivated attacker can inspect client behavior. That is acceptable for a casual memory game. If the leaderboard became high stakes, I would revisit this.
JSON sync blobs are simple, but not analytics-friendly
Storing user stats as JSON keeps the backend small. It is not ideal if the product later needs rich queries like “show all users who unlocked Tier 3 last week but stopped playing.” That can be solved later by adding event analytics or derived tables.
UTC streaks are predictable, but may feel strict
UTC day boundaries are simple and consistent. A user near midnight in their local timezone may expect a different behavior. If streak frustration becomes a real product problem, I would move toward timezone-aware daily windows.
Local-first gameplay improves feel, but needs honest UI
The app can record local progress instantly. But it should distinguish local completion from confirmed leaderboard submission. Users should not have to guess whether a score is only on their phone or already on the leaderboard.
What I would keep out of v1
I would avoid:
- multiplayer rooms
- live head-to-head play
- server-authoritative tap validation
- a custom backend framework
- heavy anti-cheat
- a complex admin dashboard
- social graph features
- real-time sockets
- complicated timezone personalization
Those may be interesting later, but they are not needed for the first strong version of this app.
The strongest architecture for Prism is small and intentional: local gameplay, serverless shared truth, simple data stores, clear sync rules, and enough reliability work to make mobile failures boring.
Final architecture takeaways
Prism is a useful system design example because it shows how much design thinking can live inside a small app.
The important decisions are:
- Keep the game loop local so play feels instant.
- Use deterministic daily seeds instead of server-stored sequences.
- Let the backend own shared leaderboard truth.
- Store useful progress locally so the app survives bad networks.
- Use an offline queue for score submissions.
- Merge cloud sync with domain-specific rules, not one generic rule.
- Treat notifications as part of the product loop, not an afterthought.
- Keep state stores small and side effects isolated in services.
- Use tests and architecture checks to protect the app as it grows.
If I had to summarize the design in one line, I would say:
Prism is an offline-friendly mobile game where the client owns the experience and the server owns shared trust.
That is a pattern worth reusing. Not every app needs this exact stack, but many polished mobile apps need the same boundary.
If you want to try it, you can download Prism at prismgameapp.com.
If this was useful, you can buy me a coffee ☕. If you have a question, correction, or a product you want me to think through next, leave a comment.
If you have seen a version of this question in an interview, I would love to hear what part felt hardest: requirements, APIs, mobile state, scale, offline behavior, or tradeoffs.
Comments
Loading…