Gamification Architecture: Designing XP, Streaks, and Achievements That Actually Work

Deleting photos is boring.
Nobody wakes up excited to scroll through 12,000 camera roll images and decide which ones to keep. It's maintenance work. The kind of task you know you should do, keep meaning to do, and never actually do. Like flossing, but for your phone.
When I started building SwipeClean, an iOS app that turns photo cleanup into a swipe-based game, I knew the core interaction was sound. Tinder for your camera roll. Swipe right to keep, swipe left to delete. Simple, satisfying, fast.
But without a progression system, the app would be a gimmick. You'd swipe for five minutes, feel good, and never open it again. The swipe mechanic gets people in the door. The gamification system is what makes them come back.
This post documents the gamification architecture I designed from scratch for SwipeClean. Every formula, every constant, every architectural decision. Not theory. Production code running on real devices.
#The Design Philosophy: Game Disguised as a Utility
Before writing any code, I established a core principle that shaped every decision that followed:
SwipeClean is a game disguised as a utility. Not a utility with game elements bolted on.
This distinction matters more than it sounds. Most "gamified" apps treat game mechanics as decoration. They slap a point counter on a settings screen and call it gamification. The user sees through it immediately.
Duolingo understood this. They didn't build a language learning app with streaks. They built a game where the side effect is that you learn Spanish. The streak isn't a feature. It's the core retention mechanism that everything else orbits around.
SwipeClean follows the same principle. The CLAUDE.md for the project states it explicitly: "a game disguised as a utility, Duolingo-style, not file-manager-style." The design system enforces it: "Dopamine-driven. Every interaction produces satisfying visual and haptic feedback."
This philosophy manifests in four design pillars:
- Every action produces feedback. Swipe right? Haptic pulse. Swipe left? Medium impact. Level up? Success notification. No action goes unacknowledged.
- Progression is always visible. XP bar on the home screen. Level badge. Streak calendar. You always know where you stand.
- Negative outcomes are soft. Losing a streak isn't punishing. You get grace periods and streak freezes. The system nudges, it doesn't punish.
- Real-world value compounds. XP is abstract. But "you freed 3.5 GB" is concrete. The game rewards map to actual phone storage recovered.
#XP Calculation: The Math Behind the Reward
XP is the atomic unit of the entire gamification system. Every other mechanic (levels, achievements, progression) derives from XP. Get the XP formula wrong, and everything built on top of it will feel broken.
>The Formula
SwipeClean uses a multi-signal XP calculation with four components:
enum XPCalculator {
static func calculate(
photosReviewed: Int,
photosDeleted: Int,
bytesFreed: Int64,
roundType: RoundType,
bossDefeated: Bool = false
) -> Int {
guard photosReviewed > 0 else { return 0 }
var xp = photosDeleted * AppConfig.xpPerPhotoDeleted
xp += AppConfig.xpRoundCompletionBonus
let mbFreed = bytesFreed / (1024 * 1024)
xp += Int(mbFreed / 100) * AppConfig.xpPer100MBBonus
if roundType == .boss && bossDefeated {
xp += AppConfig.xpBossBattleBonus
}
return xp
}
}
Breaking it down with the actual constants:
| Component | Value | Purpose |
|---|---|---|
| Per photo deleted | 1 XP | Base reward for the core action |
| Round completion | 50 XP | Guaranteed payout for showing up |
| Per 100 MB freed | 100 XP | Rewards quality over quantity |
| Boss battle bonus | 500 XP | Endgame reward spike |
>Why These Specific Numbers
1 XP per photo deleted is intentionally small. If you delete 30 photos in a round, that's 30 XP from deletions alone. Modest. This prevents the system from rewarding mindless bulk deletion. You could delete everything without looking and farm XP, but the return would be poor.
50 XP round completion bonus is the engagement anchor. Even if you only review 5 photos and delete 2, you still walk away with 52 XP. This is critical for new users. The worst thing a gamification system can do is make someone feel like they wasted their time. The completion bonus guarantees a meaningful reward for every session.
100 XP per 100 MB freed creates a secondary reward signal tied to real-world value. A user who deletes 3 large videos (say, 500 MB each) earns 1,500 XP from the storage bonus alone, even if they only deleted 3 items. This makes the system feel intelligent. It rewards impact, not just volume.
500 XP boss battle bonus is the endgame carrot. Boss battles are themed challenges ("Delete 50 screenshots," "Remove 10 videos over 100 MB") that require sustained effort. The 500 XP spike makes completing one feel significant.
>The Guard Clause
Notice the first line of the function: guard photosReviewed > 0 else { return 0 }. This prevents a subtle exploit. Without it, a user could start a round, immediately end it without reviewing any photos, and still collect the 50 XP completion bonus. The guard requires at least one photo reviewed before any XP is awarded.
>Architecture Decision: Stateless Calculator
XPCalculator is an enum with a single static function. No stored state. No side effects. All inputs are passed in as parameters.
This was a deliberate choice. The XP calculation is pure math. It takes inputs and returns a number. By keeping it stateless, I can unit test it trivially, call it from anywhere without dependency injection, and guarantee it never produces different results for the same inputs.
The GamificationService handles the messy parts: fetching the player profile, persisting the result, updating streaks, checking achievements. The calculator just does math.
#Level Progression: Designing the Curve
Levels give XP meaning. Without them, XP is just an ever-increasing number. Levels create milestones, identity, and a sense of progress.
>The Level Table
extension LevelDefinition {
nonisolated static let all: [LevelDefinition] = [
.init(level: 1, title: "Data Hoarder", xpRequired: 0),
.init(level: 2, title: "Digital Declutterer", xpRequired: 500),
.init(level: 3, title: "Photo Organizer", xpRequired: 2_000),
.init(level: 4, title: "Storage Ninja", xpRequired: 5_000),
.init(level: 5, title: "Digital Janitor", xpRequired: 10_000),
.init(level: 6, title: "Cleanup Veteran", xpRequired: 25_000),
.init(level: 7, title: "Zen Master", xpRequired: 50_000),
]
}
Seven levels. Cumulative XP thresholds. Thematic titles that evolve from "Data Hoarder" (you're the problem) to "Zen Master" (you've transcended).
>Why Seven Levels
Most gamification systems have too many levels. RPGs can get away with 100+ levels because the game itself is hundreds of hours long. A utility app that you open for 60 seconds at a time can't sustain that.
Seven levels means each one is meaningful. Hitting Level 3 actually feels like something. If there were 50 levels and you were grinding from Level 23 to Level 24, nobody would care.
The other constraint: SwipeClean v1 has no backend. Everything persists locally via SwiftData. There's no leaderboard, no social comparison, no external validation. The levels need to be intrinsically rewarding within a single-player context.
>The Progression Curve
The XP gaps between levels aren't linear. They follow an accelerating curve:
| Level | XP Required | Gap from Previous | Approx. Rounds to Reach |
|---|---|---|---|
| 1 | 0 | - | 0 |
| 2 | 500 | 500 | ~7 |
| 3 | 2,000 | 1,500 | ~20 |
| 4 | 5,000 | 3,000 | ~40 |
| 5 | 10,000 | 5,000 | ~70 |
| 6 | 25,000 | 15,000 | ~200 |
| 7 | 50,000 | 25,000 | ~350 |
The math for "approx. rounds" assumes an average of ~70 XP per standard round (30 photos deleted, 50 XP completion bonus, minimal storage bonus). Your mileage varies significantly based on photo library composition.
The key insight is the acceleration. Levels 1 through 3 come fast. A new user can reach Level 3 ("Photo Organizer") within their first week of casual use. This builds early momentum.
Levels 5 through 7 take serious sustained effort. Level 7 requires roughly 350 standard rounds. At one round per day, that's nearly a year of daily use. This is intentional. "Zen Master" should feel earned.
>Progress Fraction: The UX Layer
The raw XP number isn't useful for UI. Users need to see how far they are toward the next level, expressed as a fraction:
var levelProgressFraction: Double {
let current = LevelDefinition.level(for: totalXP)
guard let next = LevelDefinition.next(after: current.level) else { return 1.0 }
let xpIntoLevel = totalXP - current.xpRequired
let xpNeeded = next.xpRequired - current.xpRequired
guard xpNeeded > 0 else { return 1.0 }
return Double(xpIntoLevel) / Double(xpNeeded)
}
This powers the XP progress bar on the home screen. When you're Level 3 with 3,200 XP, the bar shows 40% full (1,200 XP into a 3,000 XP gap). When you hit max level, it shows 100%.
The second guard (xpNeeded > 0) prevents a division-by-zero edge case that would only trigger at max level, but defensive coding isn't optional in production.
#Streak System: Retention Through Consistency
Streaks are the most psychologically powerful mechanic in consumer app gamification. Duolingo built a multi-billion dollar business on the streak. Snapchat's early growth was fueled by streak mechanics. The reason is simple: loss aversion is stronger than reward seeking.
Gaining a 7-day streak feels good. Losing a 30-day streak feels terrible. People will open the app specifically to avoid losing the streak. That's the retention loop.
>The Core Mechanic
SwipeClean tracks streaks at the day level. Complete at least one round on a given day, and the streak continues. Miss a day, and the streak resets. Simple in concept. The complexity is in the edge cases.
private func updateStreak(profile: PlayerProfile, modelContext: ModelContext) {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
if profile.streakDays.isEmpty {
profile.currentStreak = 1
profile.longestStreak = max(profile.longestStreak, profile.currentStreak)
// Record today and return
return
}
let lastActive = calendar.startOfDay(for: profile.lastActiveDate)
if lastActive == today {
return // Already played today
}
let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
let missedDays = calendar.dateComponents([.day], from: lastActive, to: today).day ?? 0
if lastActive == yesterday {
profile.currentStreak += 1
} else if missedDays == 2, profile.streakFreezesRemaining > 0 {
profile.streakFreezesRemaining -= 1
profile.currentStreak += 1
// Record frozen day for yesterday
} else if lastActive < yesterday {
profile.currentStreak = 1
}
profile.longestStreak = max(profile.longestStreak, profile.currentStreak)
// Record today
}
>The State Machine
There are four possible states when a user opens the app on a new day:
- Consecutive day (last active was yesterday): Streak increments by 1. Standard path.
- Same day (already played today): No change. Early return.
- Missed exactly one day, freeze available: Streak freeze consumed. Streak increments. The frozen day gets a
StreakDayrecord withfroze: true. - Missed two or more days (or one day with no freeze): Streak resets to 1.
The missedDays == 2 check is subtle and worth explaining. If lastActiveDate is Monday and today is Wednesday, the user missed Tuesday. dateComponents(.day) returns 2 (the distance between Monday and Wednesday). That's one missed calendar day, not two. The naming is slightly confusing, but the logic is correct.
>Streak Freezes: The Grace Period
Hard streaks are punishing. Life happens. You get sick, your phone dies, you travel without signal. Losing a 45-day streak because you missed one day feels unfair.
Streak freezes solve this. They are consumable shields that automatically activate when you miss a single day:
| Tier | Freezes per Week | Replenish Schedule |
|---|---|---|
| Free | 1 | Weekly (calendar week boundary) |
| Pro | 3 | Weekly (calendar week boundary) |
The replenishment logic uses calendar week boundaries (locale-aware), not rolling 7-day windows:
private func replenishStreakFreezes(profile: PlayerProfile) {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let lastReset = calendar.startOfDay(for: profile.streakFreezeResetDate)
let sameWeek = calendar.isDate(today, equalTo: lastReset, toGranularity: .weekOfYear)
guard !sameWeek else { return }
let cap = profile.isProUser
? AppConfig.proStreakFreezes // 3
: AppConfig.defaultStreakFreezes // 1
profile.streakFreezesRemaining = cap
profile.streakFreezeResetDate = today
}
Why weekly replenishment instead of daily? Because streak freezes should feel scarce. One per week for free users means you get exactly one "oops" per week. Three for Pro users gives meaningful breathing room without removing the stakes entirely.
>The Evening Nudge
SwipeClean adds a behavioral nudge when your streak is at risk. If it's past 6 PM, you have a streak of at least 1 day, and you haven't played today, the app shows a streak freeze warning:
var shouldShowStreakFreeze: Bool {
currentStreak >= 1 && !hasPlayedToday && isEveningHours
}
This is directly inspired by Duolingo's push notifications ("Your streak is in danger!"). The difference is that SwipeClean does this in-app rather than via push notification, which is less intrusive but still effective for users who open the app in the evening.
>Streak Day Persistence
Every day of activity (or inactivity covered by a freeze) gets a StreakDay record in SwiftData:
@Model
final class StreakDay {
var date: Date = Date()
var completed: Bool = false
var froze: Bool = false
var profile: PlayerProfile?
}
This powers the streak calendar on the home screen, which shows the last 7 days with three visual states: completed (green), frozen (yellow), and missed (gray). The calendar isn't just decoration. It creates social proof against yourself. Seeing 6 consecutive green days makes you want to complete the 7th.
#Achievement System: The Collection Layer
Achievements serve a different psychological function than XP and streaks. XP is continuous, streaks are recurring, but achievements are discrete, permanent, and collectible.
The feeling of an achievement unlocking is qualitatively different from earning XP. It's a surprise. A recognition event. "You did something notable, and the system noticed."
>The Achievement Model
@Model
final class Achievement {
var id: String = UUID().uuidString
var title: String = ""
var achievementDescription: String = ""
var iconName: String = ""
var requiredValue: Int = 0
var metricTypeRawValue: String = "photosDeleted"
var isUnlocked: Bool = false
var unlockedAt: Date?
var profile: PlayerProfile?
}
Each achievement tracks a single metric against a threshold. When the metric crosses the threshold, the achievement unlocks. The unlockedAt timestamp records exactly when it happened, which adds personal history to the collection.
>Metric Types
Achievements evaluate against six metrics:
enum AchievementMetric: String, Codable {
case photosDeleted
case bytesFreed
case streakDays
case bossesDefeated
case roundsCompleted
case levelReached
}
This covers all major axes of engagement: volume (photos deleted), impact (bytes freed), consistency (streak days), challenge completion (bosses defeated), frequency (rounds completed), and progression (level reached).
>The Seed Data
Ten achievements ship with the app on first launch:
| ID | Title | Metric | Threshold |
|---|---|---|---|
| first-delete | First Steps | photosDeleted | 1 |
| delete-100 | Getting Started | photosDeleted | 100 |
| delete-1000 | Photo Purger | photosDeleted | 1,000 |
| free-1gb | Gigabyte Club | bytesFreed | 1,073,741,824 |
| streak-7 | Week Warrior | streakDays | 7 |
| streak-30 | Monthly Master | streakDays | 30 |
| boss-1 | Boss Slayer | bossesDefeated | 1 |
| boss-5 | Boss Hunter | bossesDefeated | 5 |
| level-7 | Zen Master | levelReached | 7 |
| rounds-100 | Centurion | roundsCompleted | 100 |
Notice the distribution. Three deletion milestones (1, 100, 1000). Two streak milestones (7, 30). Two boss milestones (1, 5). One each for storage, level, and rounds. This ensures that different play styles all unlock achievements at different rates.
"First Steps" unlocks on your first round. "Centurion" requires 100 rounds. "Zen Master" requires 50,000 XP. The spread from trivial to endgame is wide enough that every user, from casual to dedicated, will experience unlock events at a rate that feels rewarding.
>The Check Algorithm
After every round, the GamificationService evaluates all locked achievements against current profile stats:
private func checkAchievements(profile: PlayerProfile) -> [String] {
var unlocked: [String] = []
for achievement in profile.achievements where !achievement.isUnlocked {
let currentValue: Int
switch achievement.metricType {
case .photosDeleted:
currentValue = profile.totalPhotosDeleted
case .bytesFreed:
currentValue = Int(min(profile.totalBytesFreed, Int64(Int.max)))
case .streakDays:
currentValue = profile.currentStreak
case .bossesDefeated:
currentValue = profile.bossBattles.filter(\.isCompleted).count
case .roundsCompleted:
currentValue = profile.sessions.count
case .levelReached:
currentValue = profile.currentLevel
}
if currentValue >= achievement.requiredValue {
achievement.isUnlocked = true
achievement.unlockedAt = Date()
unlocked.append(achievement.title)
}
}
return unlocked
}
This runs in O(n) where n is the number of locked achievements. With 10 total achievements, this is negligible. The function returns an array of titles for any newly unlocked achievements, which the UI uses to trigger celebration animations.
The bytesFreed case includes a safety clamp: min(profile.totalBytesFreed, Int64(Int.max)). Since totalBytesFreed is Int64 (to handle large libraries) but requiredValue is Int, this prevents an overflow if someone somehow frees more than 2^63 bytes. Defensive, but cheap.
#Boss Battles: The Endgame Challenge
Standard rounds are 60 seconds of open-ended swiping. Boss battles are focused, themed challenges that give advanced users something to work toward.
>Boss Battle Structure
@Model
final class BossBattle {
var id: String = UUID().uuidString
var title: String = ""
var battleDescription: String = ""
var filterTypeRawValue: String = "mediaType"
var filterValue: String = ""
var targetCount: Int = 50
var completedCount: Int = 0
var isCompleted: Bool = false
var xpReward: Int = 500
var profile: PlayerProfile?
}
Each boss battle filters the photo library by a specific criteria and sets a deletion target. The user must delete that many matching photos to "defeat" the boss.
>The Three Bosses
| Boss | Filter | Target | Difficulty |
|---|---|---|---|
| The Screenshot Purge | Screenshots only | 50 | Medium |
| Video Vault | Videos > 100 MB | 10 | Hard (depends on library) |
| Blast from the Past | Photos > 2 years old | 100 | Easy (most libraries have these) |
The boss selection is intentionally limited. Three bosses, three distinct photo types, three difficulty levels. Each one targets a common storage problem:
- Screenshots accumulate silently. People take screenshots of addresses, receipts, memes, and forget about them. 50 is a low bar for most people.
- Large videos are the biggest storage hogs. 10 videos over 100 MB is a high bar because many users don't have that many, but the storage payoff is massive.
- Old photos are the hardest to let go of emotionally. The 2-year threshold creates enough distance that most photos feel safely deletable.
>Filter Types
Boss battles use a filter type system to query different photo library segments:
enum BossFilterType: String, Codable {
case mediaType // Screenshots, videos
case fileSize // Videos > 100MB
case age // Photos older than X years
case duplicate // AI-flagged similar photos (v2)
}
The .duplicate type is a v2 placeholder. In v1, all filtering happens through PhotoKit's built-in metadata. No on-device ML. This keeps the app size small and avoids the complexity of local image analysis.
>No Time Limit
Boss battles have no timer. Standard rounds are 60 seconds. Boss battles are untimed. This is a deliberate design choice. Boss battles are about completing a goal, not racing a clock. Removing time pressure changes the emotional tone. Standard rounds feel frenetic and exciting. Boss battles feel methodical and strategic.
#Game State Persistence: The Data Layer
All game state persists locally via SwiftData, Apple's declarative persistence framework. There's no backend. No sync. No accounts. Everything lives on-device.
>The PlayerProfile Singleton
@Model
final class PlayerProfile {
var totalXP: Int = 0
var currentLevel: Int = 1
var currentStreak: Int = 0
var longestStreak: Int = 0
var totalPhotosReviewed: Int = 0
var totalPhotosDeleted: Int = 0
var totalBytesFreed: Int64 = 0
var streakFreezesRemaining: Int = 1
var streakFreezeResetDate: Date = Date()
var isProUser: Bool = false
var lastActiveDate: Date = Date()
@Relationship(deleteRule: .cascade) var sessions: [RoundSession] = []
@Relationship(deleteRule: .cascade) var achievements: [Achievement] = []
@Relationship(deleteRule: .cascade) var bossBattles: [BossBattle] = []
@Relationship(deleteRule: .cascade) var streakDays: [StreakDay] = []
}
One PlayerProfile per device. It owns all related entities through cascade-delete relationships. Delete the profile, and all sessions, achievements, boss battles, and streak days delete with it.
The Int64 type for totalBytesFreed isn't arbitrary. A user who frees 4 GB of space produces a value of ~4,294,967,296 bytes, which overflows a 32-bit integer. Int64 handles up to 9.2 exabytes. Future-proof.
>The Processing Pipeline
When a round ends, the GamificationService orchestrates a 9-step pipeline:
- Calculate XP via
XPCalculator.calculate() - Fetch or create the
PlayerProfilesingleton - Sync Pro status and replenish weekly streak freezes
- Update profile stats (XP, photos reviewed, photos deleted, bytes freed)
- Recalculate level from total XP
- Update streak (must run before
lastActiveDateis updated) - Save round session to SwiftData
- Check achievements against updated stats
- Persist everything to disk
Step 6 has a critical ordering dependency. The streak calculation reads lastActiveDate to determine if the user has already played today. If lastActiveDate is updated before the streak check, the logic breaks. The code includes a comment flagging this: "NOTE: lastActiveDate is set AFTER updateStreak()."
This kind of ordering dependency is the most common source of gamification bugs. Two functions that seem independent but actually have an implicit temporal relationship. I enforce it through code order and comments, but in a larger system, you would want a state machine or transaction-based approach to make the dependency explicit.
>Actor Isolation
The GamificationService is @MainActor-isolated:
@MainActor
final class GamificationService: GamificationServiceProtocol { ... }
This is a concurrency safety measure. SwiftData ModelContext isn't thread-safe. If two concurrent operations tried to update the same PlayerProfile, you'd get data corruption or crashes. By isolating the entire service to @MainActor, all state mutations are serialized on the main thread. No race conditions. No locks.
The trade-off is that all gamification processing happens on the main thread, which could block UI. In practice, the processing is fast (sub-millisecond), so this isn't a real concern.
#Lessons Learned: What I Would Do Differently
After building, testing, and iterating on this system, a few things stand out.
The level count is about right, but the XP curve is too steep at the top. Level 6 to Level 7 requires 25,000 XP. At roughly 70 XP per standard round, that's ~350 rounds. For a casual user doing one round per day, that's nearly a year. I would flatten the top of the curve in v2, or add seasonal XP bonuses to accelerate endgame progression.
Streak freezes need to be more visible. Users don't always realize they have freezes available, or that a freeze was consumed. A more prominent notification ("Streak saved! 1 freeze remaining") would improve the experience.
Boss battles need more variety. Three static bosses get stale. A rotating weekly boss system, where new challenges appear every Monday, would keep the endgame fresh. The architecture supports this (the boss model is data-driven, not hardcoded), but v1 shipped with a fixed set.
Achievement seeding on first launch is fragile. The current approach seeds achievements when the HomeViewModel loads and finds an empty achievement list. This works, but it means achievements are created lazily rather than at a defined point in the app lifecycle. A migration-based approach would be more reliable.
#The Architecture Takeaway
If you're building gamification into your own app, here's what matters:
-
Keep the math simple and transparent. Users should be able to intuit why they earned what they earned. "1 XP per photo deleted" is understandable. A complex formula with diminishing returns and hidden multipliers isn't.
-
Separate calculation from persistence. Your XP calculator should be a pure function. Your state manager should handle side effects. These are different responsibilities.
-
Design for the casual user first. If your system requires 30 minutes of daily engagement to feel rewarding, you've already lost. The casual user who opens the app for 60 seconds should leave feeling good.
-
Use grace periods, not hard punishments. Streak freezes, makeup days, bonus XP events. Give people a safety net. Loss aversion drives retention, but actual loss drives churn.
-
Make progression visible. XP bars, level badges, streak calendars, achievement collections. If the user can't see their progress, the system might as well not exist.
-
Ship minimal, iterate fast. SwipeClean v1 launched with 7 levels, 10 achievements, and 3 bosses. Not 50, 100, and 20. You can always add more. You can't easily remove systems that users have already invested in.
The code referenced in this post is from a production iOS app built with SwiftUI, SwiftData, and Swift 6 strict concurrency. The patterns are transferable to any platform, but the implementation details are Apple-native.
Gamification isn't about tricking users into engagement. It's about making productive behavior feel rewarding. When deleting photos earns XP, maintains a streak, unlocks a badge, and recovers 3 GB of storage, the mundane becomes meaningful.
That's the architecture. The system is open. Explore at will.
Last updated: March 1, 2026