Johan Steen
Posted on July 15, 2022
I start early on in the development of a game to think about achievements and make notes during the development progress whenever I add something to the game that could also add some sort of additional challenge for the player in one way or another, to make it into a possible candidate to become an achievement.
Implementing achievements in a game is a great way to add some additional fun things to do in the game, and to give players more enjoyment. To be able to collect an achievement is an opportunity to make the players feel good while playing, and not only to keep grinding to collect points towards a new high score.
Achievements can also be used as hints and help to direct the player to try unusual things or to find some hidden area or mechanic they might not have found out about.
As a gamer myself, I do enjoy spending some extra time in games I buy to keep collecting achievements, event after I have beaten the game.
All in all, achievements can help to add some extra playtime to the game and give some extra joy to the players, which makes me feel its worth the extra time to implement some meaningful achievements for the players to collect.
When I did my first implementation, I immediately saw the danger that the logic to check for achievement progress could add some serious code pollution. I wanted to come up with an approach to keep it as decoupled as possible from the actual game code.
Game Center for Progress Storage
Also, I did not want to worry about storing progress myself, but instead delegated that responsibility to Apple and Game Center. By letting Game Center be the only source of truth for the player's progress, players get progress synced between all their devices as a bonus. So a player who progresses towards an achievement on the iPhone, will see the same progress advancement when opening up the game on the Apple TV.
The only drawback with this solution is that Game Center only stores integers, while a custom solution could track floats and have much more fine-grained tracking towards a goal if needing more than 100 steps.
I found the convenience of not having to store things myself and not having to make a custom iCloud-based sync solution more important, than being able to have other value types for more fine-grained progress tracking.
The Achievement Logic
I came up with an approach where I make a class for each achievement in the game, and let the class contain the unique logic to track the progress of that achievement.
Doing it this way, I can keep all the achievement logic code contained completely separate from the rest of the game code. The game code should only need to worry about when to let the achievement know when it's time to run its logic.
To handle this, I'm inheriting from GKAchievement
to add some additional logic while still being able to piggyback on the functionality to read from and report back the progress to Game Center using only one object.
class BaseAchievement: GKAchievement {
/// Determine if the achievement has unreported progress.
var isDirty: Bool = false
/// Increase progress towards getting the achievement.
func progress() {
// If achievement is already completed, no need to track progress.
guard !isCompleted else { return }
// Run the logic to update the percentage.
updatePercentComplete()
// Mark it as dirty.
isDirty = true
// Report immediately if achievement was completed.
reportIfCompleted()
}
/// Logic to update the percentage completed.
func updatePercentComplete() {
fatalError("Should be overriden in subclass")
}
/// Report the achievement if it is completed.
func reportIfCompleted() {
if isCompleted {
GameCenter.shared.report(achievement: self)
}
}
}
My BaseAchievement
class is the base for every achievement I add to the game. It gives us two additional features. First, a location for where to put the logic to track progress towards the achievement; and second, it handles deferred reporting.
Some achievements execute the progress logic quite often; for instance, an achievement to have destroyed maybe 5000 enemies could potentially run the logic in very short intervals. I don't want the game to constantly connect to Game Center servers and report every single enemy. Instead, I defer that report to occur between game levels.
Deferred Reporting
Whenever the game trigger that progress towards an achievement has happened the progress()
method is executed. Unless the achievement is already completed, the logic to update the progress happens, and the achievement is marked as isDirty
.
By marking achievements that have progress happening as isDirty
is how we later can determine which achievements that have had changes, and then batch report all of them in one go, instead of constantly reporting them one by one as progress happens.
The exception is if the progress has completed the achievements. If so, I want the report to happen right away with reportIfCompleted()
so the player gets the achievement banner shown on screen at the very moment the achievement was
completed.
Progress Logic
The updatePercentComplete()
method is where the unique logic is located for each achievement. So when we make our achievements classes, this is the only method we need to implement for each achievement.
Let's look at an achievement that will be awarded when the player has collected 100 upgrades.
/// Upgrade the Weapon 100 Times.
class UpgradeAchievement: BaseAchievement {
override func updatePercentComplete() {
percentComplete += 1.0
}
}
This class increases progress by 1% each time the player collects an upgrade, and once it reaches 100%, the achievement will be completed. That won't happen until after many game sessions, as one session might just be a few upgrades. But as mentioned, Game Center keeps track of the progress between sessions and also between devices.
As we execute code in this method, we could do any kind of logic required or any kind of conditions or comparisons for different achievement types.
Some achievements are one-shot achievements.
/// Read the rules.
class LibrarianAchievement: BaseAchievement {
override func updatePercentComplete() {
percentComplete = 100.0
}
}
The player gets the librarian achievement for reading the game's instructions page. There's not really any progress to collect, so we set the achievement to 100% right away.
So, what to do when needing more fine grained progress tracking than 1-100?
/// Destroy 1000 Drones.
class WhackDroneAchievement: BaseAchievement {
override func updatePercentComplete() {
percentComplete += 0.1
}
}
The above code could easily be mistaken for a solution, as we progress with 0.1% per event, and GKAchievement
uses a double for the progress value, and by that we get 1000 levels. Which is sort of true. That is exactly what will happen locally.
Unfortunately, Game Center only stores integers on the server even though a double is reported. So if reporting 5.4, it will only store a 5.
So we can't use Game Center to track progress with more than 100 steps. If we really needed to store more than 100 steps, we'd have to track locally and then report or track with a custom cloud saving if tracking over devices, and then report each time we reach a reportable progress of the achievement.
What I found, depending on how fast the achievement progresses, is that you many times can get away with tracking with floats locally and reporting the integer at the end of the session; it will work good enough. So, I will track with 0.1 units in the game and just accepts that if the player progress 3.9% only 3% will be recorded.
Progress will be tracked slightly slower than what the player actually does, but I believe it won't be noticeable most of the time. It's been good enough for the games I've been developing so far. It has to be some really major part of the gameplay for me to consider using a custom cloud-synced solution instead, just to get more reportable steps between devices. If not needing sync between devices, it would be a lot simpler to track progress locally between sessions before
reporting progress.
Triggering Achievement Logic
Now we just need to trigger the execution of these classes. I wanted to have an as simple API as I possible could, so there would be a minimum of additional code to add to the game logic. I definitely wanted to use a dot syntax for the achievement names.
So I came up with this, using a singleton combined with a factory for the achievements, which gives us a syntax like this.
// Record the achievement.
GameCenter.shared.progress(achievement: .librarian)
The code that handles the game's instruction page, has the above line of code to ensure the achievement gets handled.
When an enemy is destroyed in the game, when the state machine enters the destroyed state for the enemy, we can run something like this.
// Progress towards achievement.
GameCenter.shared.progress(achievement: .bring_pain)
GameCenter.shared.progress(achievement: .whack_drone)
Which records the progress towards two different achievements that are related to the event of a destroyed enemy. We don't need any conditions on these calls. As soon as an achievement is completed, these calls return immediately.
The Achievement Factory
The GameCenter
class is a singleton that we call the progress method on with the achievement in question whenever we need to record progress, using a dot syntax.
GameCenter.shared.progress(achievement: .survivor)
In turn, GameCenter
passes on the request to record progress to the relevant achievement.
func progress(achievement: Achievement) {
Achievement.achievements[achievement]?.progress()
}
To be able to do that with our achievement objects, I am using an enum that also has a factory and holds all achievement instances.
/// Manages available Game Center achievements
enum Achievement: String, CaseIterable {
case champion, collector, survivor, veteran
case librarian
}
This is what gives us the dot syntax so we get those clean and beautiful API calls from within our game code.
Moving on, we need our enum to hold instances of all our achievement objects. We need to populate each instance with the current data stored in Game Center and we need them to be ready to track progress happening at any time.
/// Collection of objects for all available achievements.
static var achievements = [Achievement: BaseAchievement]()
/// Factory to make GKAchievement objects for Game Center IDs.
static func factory(achievement: Achievement) -> BaseAchievement {
let gkAchievement: BaseAchievement
let id = achievement.rawValue
switch achievement {
case .champion: gkAchievement = ChampionAchievement(identifier: id)
case .collector: gkAchievement = CollectorAchievement(identifier: id)
case .survivor: gkAchievement = SurvivorAchievement(identifier: id)
case .veteran: gkAchievement = VeteranAchievement(identifier: id)
case .librarian: gkAchievement = LibrarianAchievement(identifier: id)
}
gkAchievement.showsCompletionBanner = true
return gkAchievement
}
We call the factory using the enum cases with dot syntax, and we get back an instance of the achievement object for the requested case. I'm pretty sure this could be done more dynamically so we don't have to use a switch statement.
That's something for future enhancements. But for now, when my games don't have more than about ten or so achievements, this has worked out.
This factory is supposed to be called when we have achievement data returned from Game Center so we can populate each underlying GKAchivement
with the current progress and the identifier the achievement has in the Game Center database.
I create the achievements in Game Center database using my enum cases as identifiers, so I can simply use the rawValue
for each case to set the correct id for each achievement before I get a response from Game Center.
/// Create a local GKAchievement object for each Game Center achievement.
static func initAchievements() {
for achievement in Self.allCases {
Self.achievements[achievement] = Self.factory(achievement: achievement)
}
}
/// Update local achievements with values from Game Center.
static func updateAchievements(_ achievements: [GKAchievement]) {
for gkAchievement in achievements {
if let achievement = Achievement(rawValue: gkAchievement.identifier) {
// Update percent completed.
Self.achievements[achievement]?.percentComplete = gkAchievement.percentComplete
}
}
}
I uses these two methods to handle the collection of achievements. With init I can create achievements at any time without having to wait for a response from Game Center. By having unconnected achievements, makes the code work even if the player does not use Game Center, so I don't need to have conditions for that.
The update method I can pass in an array of achievements and update the local copies in memory at any time with fresh data from Game Center.
Reporting Achievement Progress
During the startup of a game, where all different game systems are initialized, would be a good place to also sync up with Game Center and get the player's stored progress for the game's different achievements.
I use Game Center for other things too, like Leaderboard. So during the game's authentication process with Game Center, I end the authentication by loading the progress, if authentication was successful.
func loadAchievements() {
Achievement.initAchievements()
GKAchievement.loadAchievements { (achievements, error) in
if let error = error {
os_log(.error, log: .game_center, "Error in loading achievements: %@", String(describing: error))
}
if let achievements = achievements {
// Process the array of loaded achievements.
Achievement.updateAchievements(achievements)
}
}
}
So here is where we use the methods in the enum we created earlier. Before we ask Game Center for the achievement progress, we create an empty set of Achievement objects. The request is asynchronous and it can potentially take some time until we get a response, or maybe no response at all if the player is offline.
But if we have a successful response, we will use the update method from our enum so we have fresh data for the progress. If the player is offline, I've opted to not handle that case, and instead just not count progress for those sessions.
Depending on the game and importance of achievements, one could choose to store progress locally in that case, to sync up once the player is back online. In my case I have decided it's not worth the effort for my more casual games. I might revise that thinking for future titles.
/// Report a single achievement to game center.
func report(achievement: BaseAchievement) {
report(achievements: [achievement])
}
/// Submit the achievement report to Game Center.
func report(achievements: [BaseAchievement]) {
guard isEnabled else { return }
var achievements = achievements
// To follow best practices, remove achievements that has no new progress.
achievements = achievements.filter({ (achievement) -> Bool in
return achievement.isDirty
})
// Reset the isDirty flag in achivements with progress.
achievements.forEach { (achievement) in
achievement.isDirty = false
}
// Submit the report to game center.
GKAchievement.report(achievements) { (error) in
if let error = error {
os_log(.error, log: .game_center, "Error in reporting achievements: %@", String(describing: error))
}
}
}
And finally, we need to get updated data back to Game Center. I have two methods that handle reporting. The main one that takes a collection of achievement objects to report, and then for convenience I have a report method that takes a single achievement object and passes it on as a collection to the actual report method.
I use the convenience single achievement report method to report immediately during play time when an achievement completes, so the player gets an immediate response.
And between levels, I pass in the entire achievement collection to report progress on every achievement before the next level begins.
Before the GKAchievement.report()
call happens, which is what triggers the communication with the Game Center servers, we do a bit of tidying up. This is all happening in the GameCenter
singleton and first we check so the player has Game Center enabled. If Game Center for any reason can not be authenticated this will be set to false, and we will not attempt to report.
If the player uses Game Center and is online, we will move on. Next we check the isDirty
flag we set in our achievement objects. So we can filter out any objects that have had no progress. Sending back objects without progress won't cause any issues with the data, but Apple recommends to only report objects that have had changes.
And that pretty much sums it up.
Conclusion
This is the approach I've come up with to interact with Game Center, and to collect my players' progress towards the games different achievements while keeping the logic as separated as possible to avoid polluting the game's code with achievement logic.
I'm always dabbling with and considering new and better approaches for my different game systems, and I'd love to hear if you have any other ideas or thoughts on how to improve on these concepts. Or if you have taken a completely different approach that I haven't even thought of. If so, please let me know, don't be a stranger.
Posted on July 15, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.