🚀 Learn how to build and use this package: https://www.swiftful-thinking.com/offers/REyNLwwH
A reusable gamification system for Swift applications, built for Swift 6. Includes @Observable support.
Pre-built dependencies*:
- Mock: Included
- Firebase: https://github.com/SwiftfulThinking/SwiftfulGamificationFirebase
* Created another? Send the url in issues! 🥳
- ✅ Streaks: Track daily user activity with goals, freezes, and auto-recovery
- ✅ Experience Points: Track XP with time windows (today, week, month, year, all-time)
- ✅ Progress: Track arbitrary progress values with metadata filtering
// Streaks
Task {
try await streakManager.addStreakEvent()
print(streakManager.currentStreakData.currentStreak) // 7 days
}
// Experience Points
Task {
try await xpManager.addExperiencePoints(points: 100)
print(xpManager.currentExperiencePointsData.pointsAllTime) // 5000 XP
}
// Progress
Task {
try await progressManager.addProgress(id: "level_1", value: 0.75)
print(progressManager.getProgress(id: "level_1")) // 0.75
}Details (Click to expand)
// Streaks
let streakManager = StreakManager(
services: any StreakServices,
configuration: StreakConfiguration,
logger: GamificationLogger?
)
// Experience Points
let xpManager = ExperiencePointsManager(
services: any ExperiencePointsServices,
configuration: ExperiencePointsConfiguration,
logger: GamificationLogger?
)
// Progress
let progressManager = ProgressManager(
services: any ProgressServices,
configuration: ProgressConfiguration,
logger: GamificationLogger?
)#if DEBUG
let streakManager = StreakManager(
services: MockStreakServices(),
configuration: StreakConfiguration.mockDefault()
)
#else
let streakManager = StreakManager(
services: YourProdStreakServices(), // Your StreakServices conformance
configuration: StreakConfiguration(streakKey: "daily")
)
#endifText("Hello, world!")
.environment(streakManager)
.environment(xpManager)
.environment(progressManager)Details (Click to expand)
Each manager is initialized with a Services protocol. This is a public protocol you can use to create your own dependency.
Mock implementations are included for SwiftUI previews and testing.
// Mock with blank data
let services = MockStreakServices()
// Mock with custom data
let data = CurrentStreakData.mockActive(currentStreak: 10)
let services = MockStreakServices(streak: data)Other services are not directly included, so that the developer can pick-and-choose which dependencies to add to the project.
You can create your own services by conforming to the protocols:
@MainActor
public protocol StreakServices {
var remote: RemoteStreakService { get }
var local: LocalStreakPersistence { get }
}
@MainActor
public protocol ExperiencePointsServices {
var remote: RemoteExperiencePointsService { get }
var local: LocalExperiencePointsPersistence { get }
}
@MainActor
public protocol ProgressServices {
var remote: RemoteProgressService { get }
var local: LocalProgressPersistence { get }
}Details (Click to expand)
let config = StreakConfiguration(
streakKey: "main",
eventsRequiredPerDay: 1, // Number of events needed per day
useServerCalculation: false, // Client or server-side calculation
leewayHours: 4, // Grace period around midnight
freezeBehavior: .autoConsumeFreezes // .noFreezes, .autoConsumeFreezes, or .manuallyConsumeFreezes
)All configuration keys (streakKey, experienceKey, progressKey) are validated and must:
- Contain only alphanumeric characters and underscores
- Not contain spaces, hyphens, periods, slashes, or special characters
- Examples:
"main","daily_streak","workout"✅ - Invalid:
"user.streak","user-progress","streak/daily"❌
// Log in (starts remote listener and loads cached data)
try await streakManager.logIn(userId: "user_123")
// Log out (stops listeners and clears local data)
streakManager.logOut()// Add event for today
try await streakManager.addStreakEvent(
metadata: ["action": "completed_workout"]
)
// Get all events
let events = try await streakManager.getAllStreakEvents()
// Delete all events (for testing)
try await streakManager.deleteAllStreakEvents()// Add a freeze (protects streak for 1 day)
try await streakManager.addStreakFreeze(
id: UUID().uuidString,
dateExpires: Date().addingTimeInterval(86400 * 30) // 30 days from now
)
// Manually use freezes to save current streak
try await streakManager.useStreakFreezes()
// Get all freezes
let freezes = try await streakManager.getAllStreakFreezes()let data = streakManager.currentStreakData
// Streak info
data.currentStreak // Current streak count
data.longestStreak // All-time longest streak
data.dateStreakStart // When current streak started
data.dateLastEvent // Last event timestamp
data.status // active, atRisk, broken, canExtendWithLeeway, or noEvents
// Goal-based tracking
data.eventsRequiredPerDay // Events needed per day
data.todayEventCount // Events logged today
data.isGoalMet // Has today's goal been met?
data.goalProgress // Progress toward today's goal (0.0-1.0)
// Freeze management
data.freezesAvailableCount // Available freezes
data.freezesNeededToSaveStreak // Freezes needed to save streak
data.canStreakBeSaved // Can freezes save the streak?
// Calendar display
data.getCalendarDaysWithEvents() // All days with events (last 60 days)
data.getCalendarDaysWithEventsThisWeek() // Days with events this week// Force recalculation (useful after config changes)
streakManager.recalculateStreak()switch streakManager.currentStreakData.status {
case .noEvents:
print("No streak started yet")
case .active(let daysSinceLastEvent):
print("Active streak! Last event: \(daysSinceLastEvent) days ago")
case .atRisk:
print("Streak at risk! Log an event today!")
case .broken(let daysSinceLastEvent):
print("Streak broken. Last event: \(daysSinceLastEvent) days ago")
case .canExtendWithLeeway:
print("Within grace period! Log an event to extend your streak")
}Details (Click to expand)
let config = ExperiencePointsConfiguration(
experienceKey: "main",
useServerCalculation: false // Client or server-side calculation
)// Log in (starts remote listener and loads cached data)
try await xpManager.logIn(userId: "user_123")
// Log out (stops listeners and clears local data)
xpManager.logOut()// Add XP with metadata
try await xpManager.addExperiencePoints(
points: 100,
metadata: ["action": "completed_level", "level": 5]
)
// Get all events
let events = try await xpManager.getAllExperiencePointsEvents()
// Get events filtered by metadata
let levelEvents = try await xpManager.getAllExperiencePointsEvents(
forField: "level",
equalTo: 5
)
// Delete all events (for testing)
try await xpManager.deleteAllExperiencePointsEvents()let data = xpManager.currentExperiencePointsData
// Points by time window
data.pointsAllTime // Total XP earned (all-time)
data.pointsToday // Points earned today
data.pointsThisWeek // Points earned this week (Sunday-today)
data.pointsLast7Days // Points earned last 7 days (rolling)
data.pointsThisMonth // Points earned this month (1st-today)
data.pointsLast30Days // Points earned last 30 days (rolling)
data.pointsThisYear // Points earned this year (Jan 1-today)
data.pointsLast12Months // Points earned last 12 months (rolling)
// Event tracking
data.eventsTodayCount // Number of XP events today
data.dateLastEvent // Last event timestamp
// Timestamps
data.dateCreated // First event ever
data.dateUpdated // Last update timestamp
// Status
data.isDataStale // Data hasn't updated in 1+ hour
data.daysSinceLastEvent // Days since last XP event
// Calendar display
data.getCalendarDaysWithEvents() // All days with events (last 60 days)
data.getCalendarDaysWithEventsThisWeek() // Days with events this week// Force recalculation (useful after config changes)
xpManager.recalculateExperiencePoints()Details (Click to expand)
let config = ProgressConfiguration(
progressKey: "main"
)// Log in (bulk loads all progress and starts streaming updates)
try await progressManager.logIn(userId: "user_123")
// Log out (stops listeners and clears local data)
await progressManager.logOut()// Add or update progress (0.0 to 1.0)
try await progressManager.addProgress(
id: "level_1",
value: 0.75,
metadata: ["world": "forest", "difficulty": "hard"]
)
// Progress NEVER decreases - only increases
// Attempting to set a lower value will be ignoredProgress IDs are automatically sanitized before saving to ensure database compatibility:
- Non-alphanumeric characters are replaced with underscores
- IDs are converted to lowercase
- Examples:
"Alpha 123!"→"alpha_123""Alpha 123$"→"alpha_123""Alpha 123"→"alpha_123"
All three example IDs above would save to the same key "alpha_123", so the last write would overwrite previous values. Choose unique IDs accordingly.
// Get single progress value (synchronous)
let progress = progressManager.getProgress(id: "level_1") // 0.75
// Get full progress item (synchronous)
let item = progressManager.getProgressItem(id: "level_1")
print(item?.dateModified) // Last update time
// Get all progress values (synchronous)
let allProgress = progressManager.getAllProgress() // ["level_1": 0.75, "level_2": 0.5]
// Get all progress items (synchronous)
let allItems = progressManager.getAllProgressItems()// Get progress items by metadata field
let forestLevels = progressManager.getProgressItems(
forMetadataField: "world",
equalTo: "forest"
)
// Get max progress for filtered items
let maxForestProgress = progressManager.getMaxProgress(
forMetadataField: "world",
equalTo: "forest"
) // 0.75 (highest progress in forest world)// Delete single item
try await progressManager.deleteProgress(id: "level_1")
// Delete all items
try await progressManager.deleteAllProgress()- Synchronous reads: All get methods are synchronous (read from in-memory cache)
- Optimistic updates: UI updates immediately, remote sync happens in background
- Offline support: Pending writes queue syncs when back online
- Never decreases: Progress values only increase, never decrease
- Conflict resolution: If local is ahead of remote, local value is pushed to remote
- Metadata filtering: Filter and query progress by custom metadata fields
- Real-time sync: Automatic streaming of updates from remote
- Listener recovery: Automatically retries failed listeners
Details (Click to expand)
All events support metadata as [String: GamificationDictionaryValue]:
// Supported types
metadata["string_key"] = "value"
metadata["int_key"] = 42
metadata["double_key"] = 3.14
metadata["bool_key"] = true// Streak events - track what action triggered the event
try await streakManager.addStreakEvent(
metadata: ["action": "workout", "duration_minutes": 30]
)
// XP events - track source of XP
try await xpManager.addExperiencePoints(
points: 100,
metadata: ["source": "quest", "quest_id": "forest_1", "difficulty": "hard"]
)
// Progress - categorize and filter
try await progressManager.addProgress(
id: "level_1",
value: 0.75,
metadata: ["world": "forest", "difficulty": "hard", "stars": 2]
)
// Filter by metadata
let forestLevels = progressManager.getProgressItems(
forMetadataField: "world",
equalTo: "forest"
)Details (Click to expand)
All managers support optional analytics logging:
// Create logger (see SwiftfulLogging package)
let logger = LogManager(services: [
FirebaseAnalyticsService(),
MixpanelService()
])
// Inject into managers
let streakManager = StreakManager(
services: services,
configuration: config,
logger: logger
)StreakManager (17 events):
StreakMan_RemoteListener_Start/Success/FailStreakMan_SaveLocal_Start/Success/FailStreakMan_CalculateStreak_Start/Success/FailStreakMan_Freeze_AutoConsumedStreakMan_Freeze_ManuallyConsumedStreakMan_AddStreakFreeze_Start/Success/FailStreakMan_UseStreakFreezes_Start/Success/Fail
ExperiencePointsManager (12 events):
XPMan_RemoteListener_Start/Success/FailXPMan_SaveLocal_Start/Success/FailXPMan_CalculateXP_Start/Success/FailXPMan_AddExperiencePoints_Start/Success/Fail
ProgressManager (22 events):
ProgressMan_BulkLoad_Start/Success/FailProgressMan_RemoteListener_Start/Success/FailProgressMan_SaveLocal_Success/FailProgressMan_AddProgress_Start/Success/PendingRetry/FailProgressMan_DeleteProgress_Start/Success/FailProgressMan_DeleteAllProgress_Start/Success/FailProgressMan_UploadPendingWrites_Start/Success/FailProgressMan_UploadPendingWriteItem_Fail
All events include relevant parameters:
// Streak data
"current_streak_current_streak": 7
"current_streak_longest_streak": 30
"current_streak_today_event_count": 2
// XP data
"current_xp_points_all_time": 5000
"current_xp_points_today": 250
"current_xp_events_today_count": 3
// Progress data
"progress_id": "level_1"
"progress_value": 0.75Details (Click to expand)
All models include mock factory methods for testing:
// Blank streak (no events)
CurrentStreakData.blank(streakKey: "main")
// Default mock
CurrentStreakData.mock()
// Active streak
CurrentStreakData.mockActive(currentStreak: 10)
// At risk streak
CurrentStreakData.mockAtRisk()
// Goal-based streak
CurrentStreakData.mockGoalBased(
eventsRequiredPerDay: 3,
todayEventCount: 1
)
// Mock events
StreakEvent.mock(daysAgo: 0) // Event today
StreakEvent.mock(daysAgo: 3) // Event 3 days ago
// Mock freezes
StreakFreeze.mockUnused()
StreakFreeze.mockUsed()
StreakFreeze.mockExpired()// Blank XP (zero points)
CurrentExperiencePointsData.blank(experienceKey: "main")
// Default mock
CurrentExperiencePointsData.mock()
// Empty XP (no events)
CurrentExperiencePointsData.mockEmpty()
// Active user
CurrentExperiencePointsData.mockActive(pointsToday: 250)
// With recent events
CurrentExperiencePointsData.mockWithRecentEvents(eventCount: 10)
// Mock events
ExperiencePointsEvent.mock(daysAgo: 0, points: 100)// Mock progress item
ProgressItem.mock(
id: "level_1",
value: 0.75
)Details (Click to expand)
Both Streaks and Experience Points support server-side calculation via Firebase Cloud Functions.
Server-side calculation is optional and purely a developer preference:
- Client-side (default): Fast, works offline, no backend setup required
- Server-side: Calculations run in Cloud Functions instead of on device
-
Copy cloud functions from SwiftfulGamificationFirebase:
SwiftfulGamificationFirebase/ └── ServerCalculations/ ├── calculateStreak.ts └── calculateExperiencePoints.ts -
Deploy to Firebase:
firebase deploy --only functions
-
Enable in configuration:
let config = StreakConfiguration( streakKey: "main", eventsRequiredPerDay: 1, useServerCalculation: true, // Enable server calculation leewayHours: 4, freezeBehavior: .autoConsumeFreezes )
-
Initialize service with function name:
let service = FirebaseRemoteStreakService( rootCollectionName: "swiftful_streaks", calculateStreakCloudFunctionName: "calculateStreak" )
When useServerCalculation = true:
- Client adds event to Firestore
- Client calls
calculateStreak()orcalculateExperiencePoints() - Cloud Function reads all events
- Cloud Function performs calculation
- Cloud Function writes result to Firestore
- Client listener receives update automatically
Note: The cloud functions implement the EXACT same logic as the client-side calculators for consistency.
Details (Click to expand)
SwiftfulGamification follows the SwiftfulThinking Provider Pattern:
-
Base Package (this package):
- Zero external dependencies (except IdentifiableByString)
- Defines all protocols and models
- Includes Mock implementations
- All types are
CodableandSendable
-
Implementation Packages (separate SPM):
- SwiftfulGamificationFirebase: Firebase implementation
- Implements service protocols
- Handles provider-specific logic
- Extension files for model conversions
-
Manager Classes:
@MainActorfor UI thread safety@Observablefor SwiftUI integration- Dependency injection via protocols
- Optional logger for analytics
- Comprehensive event tracking
- Swift 6 concurrency: Full async/await support
- Thread safety:
@MainActorisolation,Sendableconformance - SwiftUI ready:
@Observablesupport - Offline first: Local persistence with remote sync
- Optimistic updates: Immediate UI updates
- Real-time sync: AsyncStream-based listeners
- Type-safe: Protocol-based architecture
- Testable: Mock implementations included
SwiftfulGamification/
├── Streaks/
│ ├── StreakManager.swift
│ ├── Services/
│ │ ├── StreakService.swift (protocols)
│ │ ├── Remote/ (remote storage)
│ │ └── Local/ (local persistence)
│ ├── Models/
│ │ ├── CurrentStreakData.swift
│ │ ├── StreakEvent.swift
│ │ ├── StreakFreeze.swift
│ │ └── StreakConfiguration.swift
│ └── Utilities/
│ └── StreakCalculator.swift (pure functions)
├── ExperiencePoints/
│ ├── ExperiencePointsManager.swift
│ ├── Services/
│ ├── Models/
│ └── Utilities/
├── Progress/
│ ├── ProgressManager.swift
│ ├── Services/
│ └── Models/
└── Shared/
└── Models/ (shared types)
- iOS 17.0+
- Swift 6.1+
- Xcode 16.0+
dependencies: [
.package(url: "https://github.com/SwiftfulThinking/SwiftfulGamification.git", branch: "main")
]Community contributions are encouraged! Please ensure that your code adheres to the project's existing coding style and structure.
- Open an issue for issues with the existing codebase.
- Open a discussion for new feature requests.
- Submit a pull request when the feature is ready.
- SwiftfulGamificationFirebase - Firebase implementation
- SwiftfulLogging - Analytics logging
- SwiftfulStarterProject - Full integration example
MIT License. See LICENSE file for details.