Setting up Steam Achievements in Unity C#

Hey everybody, my name is Steven and I'm the lead programmer over here at Mutant Studios. We've been hard at work on our new game Questr and it's finally entering it's final stages for launch. A couple weeks ago we started getting into Steam integration, which included Achievements. Being unfamiliar with Steamworks the first thing I did was go to google. The top result I found with example code was this GitHub repo, which is a C# port of the official Steamworks example. This example was an excellent starting point to get going quickly, but it was very quickly apparent that a more flexible system was needed. I'll go over the issues that I had one by one, then show the ways that I improved upon the given code.
example code:
      private enum Achievement : int {  
           ACH_WIN_ONE_GAME,  
           ACH_WIN_100_GAMES,  
           ACH_HEAVY_FIRE,  
           ACH_TRAVEL_FAR_ACCUM,  
           ACH_TRAVEL_FAR_SINGLE,  
      };  
      private Achievement_t[] m_Achievements = new Achievement_t[] {  
           new Achievement_t(Achievement.ACH_WIN_ONE_GAME, "Winner", ""),  
           new Achievement_t(Achievement.ACH_WIN_100_GAMES, "Champion", ""),  
           new Achievement_t(Achievement.ACH_TRAVEL_FAR_ACCUM, "Interstellar", ""),  
           new Achievement_t(Achievement.ACH_TRAVEL_FAR_SINGLE, "Orbiter", "")  
      };  
      // Our GameID  
      private CGameID m_GameID;  
      // Did we get the stats from Steam?  
      private bool m_bRequestedStats;  
      private bool m_bStatsValid;  
      // Should we store stats this frame?  
      private bool m_bStoreStats;  
      // Current Stat details  
      private float m_flGameFeetTraveled;  
      private float m_ulTickCountGameStart;  
      private double m_flGameDurationSeconds;  
      // Persisted Stat details  
      private int m_nTotalGamesPlayed;  
      private int m_nTotalNumWins;  
      private int m_nTotalNumLosses;  
      private float m_flTotalFeetTraveled;  
      private float m_flMaxFeetTraveled;  
      private float m_flAverageSpeed;  
      protected Callback<UserStatsReceived_t> m_UserStatsReceived;  
      protected Callback<UserStatsStored_t> m_UserStatsStored;  
      protected Callback<UserAchievementStored_t> m_UserAchievementStored;  

Starting from the top down, lets look at the first major issue. Immediately we see that we have the class that is responsible for sending and receiving achievement data from steam is also tracking each achievement as well as their values. As a system grows and develops, and the conditions for achievements gets more complex, this type of system will start to get very tedious very quickly.

Further down we see the example code's Achievement checking logic

 private void Update() {  
           if (!SteamManager.Initialized)  
                return;  
           if (!m_bRequestedStats) {  
                // Is Steam Loaded? if no, can't get stats, done  
                if (!SteamManager.Initialized) {  
                     m_bRequestedStats = true;  
                     return;  
                }  
                // If yes, request our stats  
                bool bSuccess = SteamUserStats.RequestCurrentStats();  
                // This function should only return false if we weren't logged in, and we already checked that.  
                // But handle it being false again anyway, just ask again later.  
                m_bRequestedStats = bSuccess;  
           }  
           if (!m_bStatsValid)  
                return;  
           // Get info from sources  
           // Evaluate achievements  
           foreach (Achievement_t achievement in m_Achievements) {  
                if (achievement.m_bAchieved)  
                     continue;  
                switch (achievement.m_eAchievementID) {  
                     case Achievement.ACH_WIN_ONE_GAME:  
                          if (m_nTotalNumWins != 0) {  
                               UnlockAchievement(achievement);  
                          }  
                          break;  
                     case Achievement.ACH_WIN_100_GAMES:  
                          if (m_nTotalNumWins >= 100) {  
                               UnlockAchievement(achievement);  
                          }  
                          break;  
                     case Achievement.ACH_TRAVEL_FAR_ACCUM:  
                          if (m_flTotalFeetTraveled >= 5280) {  
                               UnlockAchievement(achievement);  
                          }  
                          break;  
                     case Achievement.ACH_TRAVEL_FAR_SINGLE:  
                          if (m_flGameFeetTraveled >= 500) {  
                               UnlockAchievement(achievement);  
                          }  
                          break;  
                }  
           }  
           //Store stats in the Steam database if necessary  
           if (m_bStoreStats) {  
                // already set any achievements in UnlockAchievement  
                // set stats  
                SteamUserStats.SetStat("NumGames", m_nTotalGamesPlayed);  
                SteamUserStats.SetStat("NumWins", m_nTotalNumWins);  
                SteamUserStats.SetStat("NumLosses", m_nTotalNumLosses);  
                SteamUserStats.SetStat("FeetTraveled", m_flTotalFeetTraveled);  
                SteamUserStats.SetStat("MaxFeetTraveled", m_flMaxFeetTraveled);  
                // Update average feet / second stat  
                SteamUserStats.UpdateAvgRateStat("AverageSpeed", m_flGameFeetTraveled, m_flGameDurationSeconds);  
                // The averaged result is calculated for us  
                SteamUserStats.GetStat("AverageSpeed", out m_flAverageSpeed);  
                bool bSuccess = SteamUserStats.StoreStats();  
                // If this failed, we never sent anything to the server, try  
                // again later.  
                m_bStoreStats = !bSuccess;  
           }  
      }  


This section in particular stood out to me as something that needed to be addressed right away for several reasons. Checking all achievements every single frame is extremely needless, putting achievement logic inside of the code that is handling sending and receiving data from Steam will definitely get messy quickly, and having the names of each achievement typed in as literals is generally poor practice.

Before getting started on reworking the example code that was given, I wrote out my goals for the system, primary being to
-Separate the achievement logic from the Steam communication logic
-Handle achievement logic without adding a mess to our games gameplay code
-Make it easy to manage the identifiers of our achievements for steam communication

This is the final system that I came up with:
First we have the achievement system and checker. I wanted the achievement system to only manage communication with steam, and put all of the achievement checking logic in it's own class.


 using System.Collections;  
 using UnityEngine;  
 using Steamworks;  
 public class AchievementSystem : MonoBehaviour {  
   private static AchievementSystem _achievementSystem;  
   public static AchievementSystem Instance  
   {  
     get  
     {  
       if(_achievementSystem == null)  
       {  
         if(FindObjectOfType<AchievementSystem>() != null)  
         {  
           _achievementSystem = FindObjectOfType<AchievementSystem>();  
           return _achievementSystem;  
         }  
         else  
         {  
           Debug.LogError("Something in the scene is looking for the Achievement System but there isn't one to be found");  
           return null;  
         }  
       }  
       else  
       {  
         return _achievementSystem;  
       }  
     }  
   }  
   //our game ID  
   private CGameID m_GameID;  
   //did we get stats from Steam?  
   private bool m_bRequestedStats;  
   private bool m_bStatsValid;  
   //should we store stats?  
   private bool m_bStoreStats;  
   protected Callback<UserStatsReceived_t> m_UserStatsReceived;  
   protected Callback<UserStatsStored_t> m_UserStatsStored;  
   protected Callback<UserAchievementStored_t> m_UserAchievementStored;  
   void OnEnable()  
   {  
     if(_achievementSystem == null)  
     {  
       _achievementSystem = this;  
     }  
     if (!SteamManager.Initialized)  
     {  
       return;  
     }  
     //get our game ID for callbacks  
     m_GameID = new CGameID(SteamUtils.GetAppID());  
     m_UserStatsReceived = Callback<UserStatsReceived_t>.Create(OnUserStatsReceived);  
     m_UserStatsStored = Callback<UserStatsStored_t>.Create(OnUserStatsStored);  
     m_UserAchievementStored = Callback<UserAchievementStored_t>.Create(OnAchievementStored);  
     //these need to be reset to get stats upon Assembly reload in editor  
     m_bRequestedStats = false;  
     m_bStatsValid = false;  
     //request stats  
     StartCoroutine("GetStats");  
   }  
   IEnumerator GetStats()  
   {  
     while (!SteamManager.Initialized)  
     {  
       yield return new WaitForEndOfFrame();  
     }  
     bool bSuccess = false;  
     if (!m_bRequestedStats)  
     {  
       m_bRequestedStats = true;  
       //request stats  
       bSuccess = SteamUserStats.RequestCurrentStats();  
       m_bRequestedStats = bSuccess;  
     }  
   }  
   void StoreStats()  
   {  
     SteamUserStats.StoreStats();  
   }  
   public void UnlockAchievement(string achievementID)  
   {  
     if (SteamManager.Initialized)  
     {  
       SteamUserStats.SetAchievement(achievementID);  
       StoreStats();  
     }      
   }  
   public void UpdateStat(string statID, int newValue)  
   {  
     if (SteamManager.Initialized)  
     {  
       SteamUserStats.SetStat(statID, newValue);  
       StoreStats();  
     }  
   }  
   private void OnUserStatsReceived(UserStatsReceived_t pCallback)  
   {  
     {  
       if (!SteamManager.Initialized)  
         return;       
       // we may get callbacks for other games' stats arriving, ignore them  
       if ((ulong)m_GameID == pCallback.m_nGameID)  
       {  
         if (EResult.k_EResultOK == pCallback.m_eResult)  
         {  
           // Debug.Log("Received stats and achievements from Steam\n");  
           m_bStatsValid = true;                 
         }  
         else  
         {  
           Debug.Log("RequestStats - failed, " + pCallback.m_eResult);  
         }  
       }  
     }  
   }  
   private void OnUserStatsStored(UserStatsStored_t pCallback)  
   {  
     // we may get callbacks for other games' stats arriving, ignore them  
     if ((ulong)m_GameID == pCallback.m_nGameID)  
     {  
       if (EResult.k_EResultOK == pCallback.m_eResult)  
       {  
         //Debug.Log("StoreStats - success");  
       }  
       else if (EResult.k_EResultInvalidParam == pCallback.m_eResult)  
       {  
         // One or more stats we set broke a constraint. They've been reverted,  
         // and we should re-iterate the values now to keep in sync.  
         Debug.Log("StoreStats - some failed to validate");  
         // Fake up a callback here so that we re-load the values.  
         UserStatsReceived_t callback = new UserStatsReceived_t();  
         callback.m_eResult = EResult.k_EResultOK;  
         callback.m_nGameID = (ulong)m_GameID;  
         OnUserStatsReceived(callback);  
       }  
       else  
       {  
         Debug.Log("StoreStats - failed, " + pCallback.m_eResult);  
       }  
     }  
   }  
   private void OnAchievementStored(UserAchievementStored_t pCallback)  
   {  
     // We may get callbacks for other games' stats arriving, ignore them  
     if ((ulong)m_GameID == pCallback.m_nGameID)  
     {  
       if (0 == pCallback.m_nMaxProgress)  
       {  
         // Debug.Log("Achievement '" + pCallback.m_rgchAchievementName + "' unlocked!");  
       }  
       else  
       {  
         Debug.Log("Achievement '" + pCallback.m_rgchAchievementName + "' progress callback, (" + pCallback.m_nCurProgress + "," + pCallback.m_nMaxProgress + ")");  
       }  
     }  
   }  
 }  

The strength in this is that I can do all kinds of wonky conditions for weird achievements, which can be inherently messy, but now that mess is contained and away from anything else. I also didn't want my gameplay code to do any of the logic of achievements. Instead, my gameplay code just sends my achievement checker data, and it decides what to do with that data. If we want to change or update any achievements, we can access it all in the same place.


   //we'll just call this to check all kinds of stuff when we finish a quest  
   public void FinishQuest(AdventureData data)  
   {  
     if(data.curQuest.isRevengeQuest)  
     {  
       AchievementSystem.Instance.UnlockAchievement(Common.AchievementLookUp.REVENGE_QUEST);  
     }  
     // Debug.LogError(data.getSuccessChance + " success chance");  
     int successChance = data.getMoraleFromPref + data.getMoraleFromEncoutners + data.getMoraleFromTraits;  
     if(successChance > 100)  
     {  
       AchievementSystem.Instance.UnlockAchievement(Common.AchievementLookUp.HIGH_MORALE);  
     }  
   }  

 When the achievement has been unlocked, it passes that information on to the achievement system. This means that I can also check for multiple achievements with the same piece of information that is being sent from the gameplay code.  The last piece to this is the Common class. Common is a class I have set up with structs full of constant values.


   public struct AchievementLookUp {  
     public const string EX_FRIEND = "FRIEND_ACHIEVEMENT_1";  
     public const string BFF_FRIEND = "FRIEND_ACHIEVEMENT_2";  
     public const string BFF2_FRIEND = "FRIEND_ACHIEVEMENT_3";  
     public const string BRIBE_FRIEND = "FRIEND_ACHIEVEMENT_4";  
     public const string TAVERN_LEVEL_UP = "TIER_ACHIEVEMENT_1";  
     public const string TIER_TWO = "TIER_ACHIEVEMENT_2";  
     public const string TIER_FIVE = "TIER_ACHIEVEMENT_3";  
     public const string CAMP_LEVEL_5 = "TIER_ACHIEVEMENT_4";  
     public const string FIVE_THOU_GOLD = "TIER_ACHIEVEMENT_5";  
     public const string REVENGE_QUEST = "QUEST_ACHIEVEMENT_1";  
     public const string HIGH_MORALE = "QUEST_ACHIEVEMENT_2";  
   }  

 All I had to do was add a struct for achievements, and then I could store my steam ID's there. This keeps all of my achievement ID's in a nice contained place, while also letting me access them in a more human readable way.

This new system is easily expandable, readable, and much faster. There is no longer any Update or FixedUpdate in any code. Everything is now event driven, only calling code when something has happened, rather than checking every frame if things have happened.

If you have any questions or comments on my take at Achievements feel free to post them here, Thanks!






Comments

Popular posts from this blog

Foundations

Announcement