Implementing Weekly Streak in offline Godot game


(Этот пост обсуждает как спрограммировать еженедельный стрик в Godot. Так как пост большой и для узкого круга интереса, то я пропущу перевод данного поста на Русский.)

(^ says it won't get translated into Russian due to post being big and only interesting to people who already know how to program.)


Let's not waste time. You want to implement Weekly Streak mechanic in Godot without server and without plugins. This means that players can modify date to cheat the game and you can't do anything about it. And it's OK! That means that we will use this mechanic for fun things only, not for any kind of e-commerce! Video Games are played as voluntary challenge and in this situation you are to assume honesty of player, that they accept so they can get unique experience!

Why I chose Weekly instead of Daily? I just think it's more fun for beginner game. I just want players to check new updates regularly, not be addicted.

What is Week in Weekly? I do not mean "7 days since last event". It's a week the way they appear in calendar of player. If they did the event at Sunday of last week and did it again at Monday of this week, it's valid streak. It's far more natural than player counting 168 hours in mind and I think we can put more challenging event because of that!

That means that our dates calculation has to be close to calendar in their country, not internation al UTC nor "168" hours! We just take local date from OS and not worry about leap seconds or timezones or summer time! It is the truth.

But we need number of week to compare them to each other.cBut no method in Godot Time singleton can give us week of the year. And we do not want week of the year neither! The last week of previous year can end in the middle and be a new week of the year, or it can end at the edge. That is too much complex decision. So instead, to get a week number we will do the following:

func get_week_number(date_str, first_weekday):
     var unix_time = Time.get_unix_time_from_datetime_string(date_str)
     var days = int(unix_time / (24*60*60))
     match first_weekday:
         Time.WEEKDAY_MONDAY:
             days += 3
         Time.WEEKDAY_TUESDAY:
             days += 2
         Time.WEEKDAY_WEDNESDAY:
             days += 1
         Time.WEEKDAY_THURSDAY:
             days += 0
         Time.WEEKDAY_FRIDAY:
             days += 6
         Time.WEEKDAY_SATURDAY:
             days += 5
         Time.WEEKDAY_SUNDAY:
             days += 4
         _:
             assert(false)
     var weeks = int(days / 7)
     return weeks

(Don't know how to copy Godot GDScript into Itch.io blog neatly so it works when copied back, SORRY!)

In the code, we assume that "date_str" is local time saved as a string, as Time singleton gives us from "Time.get_date_string_from_system()". String was chosen so you can save it easily in save files. Next, we translate it into unix time! If we try to get unix time from OS, it will give us UTC, but we want local time! Local time of seconds from 1970 January 1. Do not mind this random date, we only need a single epoch to count weeks from, which is what unix time gives us. The absolute number of week too is not important, we need some number to compare weeks with each other. Division by 24*60*60 gives us days.

Next, naively we would divide by 7 days to get weeks. BUT 1970 January 1 was not a start of the week. It was Thursday. AND each country starts a week on a different day. That day is given to function through "first_weekday" argument, which we will get later. "Match" block adds the amount of days to January 1 so it it remains on Week 0 while division by 7 will give us weeks since country start of a week, so it fits to calendar 7 days division of a week.


Godot gives no access to directly ask OS about the first weekday, so we will have to get it from OS country locale string, which we will code later. First, a code to get first weekday from locale country string.

func get_first_weekday(country_string, default_weekday = Time.WEEKDAY_MONDAY):
      match country_string:
          "001",\
          "AD", "AI", "AL", "AM", "AN", "AR", "AT", "AU", "AX", "AZ",\
          "BA", "BE", "BG", "BM", "BN", "BY",\
          "CH", "CL", "CM", "CN", "CR", "CY", "CZ",\
          "DE", "DK",\
          "EC", "EE", "ES",\
          "FI", "FJ", "FO", "FR",\
          "GB", "GE", "GF", "GP", "GR",\
          "HR", "HU",\
          "IE", "IS", "IT",\
          "KG", "KZ",\
          "LB", "LI", "LK", "LT", "LU", "LV",\
          "MC", "MD", "ME", "MK", "MN", "MQ", "MY",\
          "NL", "NO", "NZ",\
          "PL",\
          "RE", "RO", "RS", "RU",\
          "SE", "SI", "SK", "SM",\
          "TJ", "TM", "TR",\
          "UA", "UY", "UZ",\
          "VA", "VN",\
          "XK":
              return Time.WEEKDAY_MONDAY
          "MV":
              return Time.WEEKDAY_FRIDAY
          "AE", "AF", "BH", "DJ", "DZ", "EG", "IQ",\
          "IR", "JO", "KW", "LY", "OM", "QA", "SD", "SY":
              return Time.WEEKDAY_SATURDAY
          "AG", "AS",\
          "BD", "BR", "BS", "BT", "BW", "BZ",\
          "CA", "CO",\
          "DM", "DO",\
          "ET",\
          "GT", "GU",\
          "HK", "HN",\
          "ID", "IL", "IN",\
          "JM", "JP",\
          "KE", "KH", "KR",\
          "LA",\
          "MH", "MM", "MO", "MT", "MX", "MZ",\
          "NI", "NP",\
          "PA", "PE", "PH", "PK", "PR", "PT", "PY",\
          "SA", "SG", "SV",\
          "TH", "TT", "TW",\
          "UM", "US",\
          "VE", "VI",\
          "WS",\
          "YE",\
          "ZA", "ZW":
              return Time.WEEKDAY_SUNDAY
          _:
              return default_weekday

You should change "default_weekday" which you prefer.

The data here is taken from The Unicode Common Locale Data Repository at https://cldr.unicode.org/ which keeps locale tied info all of the world! If you download archive, you will find the data I used in code in "common\supplemental\supplementalData.xml" at "<weekData>". Algorithm for deciding on firstDay of the week is in https://unicode.org/reports/tr35/tr35-dates.html#Week_Data. Our algorithm is very simplified, it only uses country locale when available. I think that gets 99% of them without code complexity!

Next, Godot only gives us a whole OS locale string or only the language part of it. We need to get country string from "OS.get_locale()" that we can use in our function above. Here is the code to get just country string:

var REGEX_IS_COUNTRY = RegEx.create_from_string(r"^(\d{3}|[A-Z]{2,3})$")
# will return "" if fails
func get_country_from_locale(locale):
     var extra_at = locale.find("@")
     if extra_at != -1:
         locale = locale.substr(0, extra_at)
          var second_slice = locale.get_slice("_", 1)
     if second_slice == "" or second_slice == locale:
         return ""
     if string_is_country(second_slice):
          return second_slice
          var third_slice = locale.get_slice("_", 2)
     if third_slice == "":
         return ""
     if string_is_country(third_slice):
          return third_slice
          return ""
func string_is_country(str):
     if REGEX_IS_COUNTRY.search(str):
         return true
     return false

The algorithm is based on Godot Docs OS singleton page explanation of "get_locale()". First, we remove the part of optional string after @, if it exists. Then, country, which too is optional, could be the second or the third slice. We use Regex to check first. Usually OS would give us only 2 capital letter code, but standard of locale on wiki claims it could be 3 digits or 2 capital letters or 3 capital letters. More general regex won't hurt us. And acept that sometimes we won't get a country at all.

Now we know how to get a week global number from a date. 

Since we are doing it offline, we can do checks only when game runs. Our idea is that we want Event to happen during next week after the last time Event happened, to increase streak. If Event happens on same week, nothing changes. If we are more than two weeks apart, the streak has to be reset. In this case the check should run as soon as game is launched, not on Event.

var weekly_streak = 0 
var last_event_date_str = ""
func on_weekly_streak_event():
     var current_date_str = Time.get_date_string_from_system()
     var current_week = get_week_number(current_date_str)
     if not last_event_date_str.is_empty():
         var last_event_week = get_week_number(last_event_date_str)
         if current_week - last_event_week >= 1:
             weekly_streak += 1
             last_event_date_str = current_date_str
     else:
         weekly_streak += 1
         last_event_date_str = current_date_str
     save_to_file()
func weekly_streak_check_on_launch():
     var launch_date_str = Time.get_date_string_from_system()
     var launch_week = get_week_number(launch_date_str)     
     if not last_event_date_str.is_empty():
         var last_event_week = get_week_number(last_event_date_str)
         if launch_week - last_event_week > 1:
             weekly_streak = 0
             last_event_date_str = ""
             
    save_to_file()

"on_weekly_streak_event" has to be called when Event is accomplished. "weekly_streak_check_on_launch" is to be called on game launch.

Event can mean both a challenge completed or simple login. If Event is a login, you should call "weekly_streak_check_on_launch" before "on_weekly_streak_event" so you don't get awarded with a streak if it's past the next week.

If you think that player will run the game for multiple days or at the week edge, you can call "weekly_streak_check_on_launch" periodically while game is running, not just at game launch.

"save_to_file()" is a something you should implement and is just a hint to programmer that it should be saved now. Last event time and weekly streak have to be overwritten in the file. Before each of those functions are called, you have to load data on game launch first from a file if they exist, to fill weekly_streak and last_event_date_str!

You can see the formula follows:

If no last Event happened for current streak, just increase streak and write down date on Event. Then when next Event happens, increase streak and write down date only if we are on the next week. Otherwise, if we are past that week, our launch check function should catch it and reset the streak and date.


All of this code could be improved to be more general and catch edge cases better by being more complex, such as players running game for multiple days. Or player OS having customized first day of the week different from their country locale. We also can allow player to pick the first weekday in options, but that can be easily gamed, or it can too easily take away streak if it lands on the wrong date after a change! 


I think this should be good enough for all Godot authors. If you simply want forgiving Weekly Reward then you simply don't run "weekly_streak_check_on_launch". And I am sure you can now easily figure out how to do Daily, Monthly, Yearly rewards or streak too.

Leave a comment

Log in with itch.io to leave a comment.