Skip to content

SwiftfulThinking/SwiftfulGamification

Repository files navigation

🚀 Learn how to build and use this package: https://www.swiftful-thinking.com/offers/REyNLwwH

Gamification Manager for Swift 6 🎮

A reusable gamification system for Swift applications, built for Swift 6. Includes @Observable support.

Platform: iOS

Pre-built dependencies*:

* Created another? Send the url in issues! 🥳

Features

  • 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

Quick Examples

// 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
}

Setup

Details (Click to expand)

Create instances of managers:

// 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?
)

Development vs Production:

#if DEBUG
let streakManager = StreakManager(
    services: MockStreakServices(),
    configuration: StreakConfiguration.mockDefault()
)
#else
let streakManager = StreakManager(
    services: YourProdStreakServices(),  // Your StreakServices conformance
    configuration: StreakConfiguration(streakKey: "daily")
)
#endif

Optionally add to SwiftUI environment as @Observable

Text("Hello, world!")
    .environment(streakManager)
    .environment(xpManager)
    .environment(progressManager)

Inject dependencies

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 }
}

Streaks

Details (Click to expand)

Configuration

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
)

⚠️ Important: Key Sanitization

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 / Log Out

// 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 Streak Events

// 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()

Streak Freezes

// 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()

Access Current Streak Data

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

Recalculate Streak

// Force recalculation (useful after config changes)
streakManager.recalculateStreak()

Streak Status

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")
}

Experience Points

Details (Click to expand)

Configuration

let config = ExperiencePointsConfiguration(
    experienceKey: "main",
    useServerCalculation: false  // Client or server-side calculation
)

Log In / Log Out

// 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 Experience Points

// 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()

Access Current XP Data

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

Recalculate XP

// Force recalculation (useful after config changes)
xpManager.recalculateExperiencePoints()

Progress

Details (Click to expand)

Configuration

let config = ProgressConfiguration(
    progressKey: "main"
)

Log In / Log Out

// 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 Progress

// 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 ignored

⚠️ Important: ID Sanitization

Progress 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 Progress

// 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()

Filter by Metadata

// 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 Progress

// Delete single item
try await progressManager.deleteProgress(id: "level_1")

// Delete all items
try await progressManager.deleteAllProgress()

Progress Features

  • 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

Metadata System

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

Use Cases

// 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"
)

Analytics Integration

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
)

Tracked Events

StreakManager (17 events):

  • StreakMan_RemoteListener_Start/Success/Fail
  • StreakMan_SaveLocal_Start/Success/Fail
  • StreakMan_CalculateStreak_Start/Success/Fail
  • StreakMan_Freeze_AutoConsumed
  • StreakMan_Freeze_ManuallyConsumed
  • StreakMan_AddStreakFreeze_Start/Success/Fail
  • StreakMan_UseStreakFreezes_Start/Success/Fail

ExperiencePointsManager (12 events):

  • XPMan_RemoteListener_Start/Success/Fail
  • XPMan_SaveLocal_Start/Success/Fail
  • XPMan_CalculateXP_Start/Success/Fail
  • XPMan_AddExperiencePoints_Start/Success/Fail

ProgressManager (22 events):

  • ProgressMan_BulkLoad_Start/Success/Fail
  • ProgressMan_RemoteListener_Start/Success/Fail
  • ProgressMan_SaveLocal_Success/Fail
  • ProgressMan_AddProgress_Start/Success/PendingRetry/Fail
  • ProgressMan_DeleteProgress_Start/Success/Fail
  • ProgressMan_DeleteAllProgress_Start/Success/Fail
  • ProgressMan_UploadPendingWrites_Start/Success/Fail
  • ProgressMan_UploadPendingWriteItem_Fail

Event Parameters

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.75

Mock Factories

Details (Click to expand)

All models include mock factory methods for testing:

Streaks

// 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()

Experience Points

// 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)

Progress

// Mock progress item
ProgressItem.mock(
    id: "level_1",
    value: 0.75
)

Server Calculations

Details (Click to expand)

Both Streaks and Experience Points support server-side calculation via Firebase Cloud Functions.

Why Server Calculation?

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

Setup

  1. Copy cloud functions from SwiftfulGamificationFirebase:

    SwiftfulGamificationFirebase/
    └── ServerCalculations/
        ├── calculateStreak.ts
        └── calculateExperiencePoints.ts
    
  2. Deploy to Firebase:

    firebase deploy --only functions
  3. Enable in configuration:

    let config = StreakConfiguration(
        streakKey: "main",
        eventsRequiredPerDay: 1,
        useServerCalculation: true,  // Enable server calculation
        leewayHours: 4,
        freezeBehavior: .autoConsumeFreezes
    )
  4. Initialize service with function name:

    let service = FirebaseRemoteStreakService(
        rootCollectionName: "swiftful_streaks",
        calculateStreakCloudFunctionName: "calculateStreak"
    )

How It Works

When useServerCalculation = true:

  1. Client adds event to Firestore
  2. Client calls calculateStreak() or calculateExperiencePoints()
  3. Cloud Function reads all events
  4. Cloud Function performs calculation
  5. Cloud Function writes result to Firestore
  6. Client listener receives update automatically

Note: The cloud functions implement the EXACT same logic as the client-side calculators for consistency.

Architecture

Details (Click to expand)

SwiftfulGamification follows the SwiftfulThinking Provider Pattern:

  1. Base Package (this package):

    • Zero external dependencies (except IdentifiableByString)
    • Defines all protocols and models
    • Includes Mock implementations
    • All types are Codable and Sendable
  2. Implementation Packages (separate SPM):

    • SwiftfulGamificationFirebase: Firebase implementation
    • Implements service protocols
    • Handles provider-specific logic
    • Extension files for model conversions
  3. Manager Classes:

    • @MainActor for UI thread safety
    • @Observable for SwiftUI integration
    • Dependency injection via protocols
    • Optional logger for analytics
    • Comprehensive event tracking

Key Features

  • Swift 6 concurrency: Full async/await support
  • Thread safety: @MainActor isolation, Sendable conformance
  • SwiftUI ready: @Observable support
  • 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

File Structure

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)

Requirements

  • iOS 17.0+
  • Swift 6.1+
  • Xcode 16.0+

Installation

Swift Package Manager

dependencies: [
    .package(url: "https://github.com/SwiftfulThinking/SwiftfulGamification.git", branch: "main")
]

Contributing

Community contributions are encouraged! Please ensure that your code adheres to the project's existing coding style and structure.

Related Packages

License

MIT License. See LICENSE file for details.

About

User Streaks, XP, and Progress tracking

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages