(On a side note, Google has a pretty sweet internal tool for creating memes; I'm going to miss it...) |
tl;dr: Leaderboards are very hard to implement in general, and even harder to implement in Godot. In the end, I designed an end-to-end system that works, but decided not to implement it, since it would have a few unacceptable limitations.
Update (2021/02/23): Godot is getting support for iOS plugins very soon in v3.2.4. This should make it easier to use native libraries, which is definitely the right way to go about creating a leaderboard in the future!
Sorry, this is a pretty long and technical post! But maybe it'll possibly be useful for people who are interested in implementing their own leaderboards or authentication systems in Godot.
A tale of woe
It all started out on a cheery Thursday morning. My 10-month-old baby had allowed me to have a relatively great night of sleep. I had just wrapped up a few threads of work on my game. I was feeling optimistic. Life was good.
Then I thought to myself, "Ooh, a leaderboard! That's what my game needs. It'll make the players feel compelled to play again and again for a higher position. What a great idea! Good job Levi!!"
And that's when the trouble began...
Leaderboards have some well-known problems
Anything I made would at least need to be able to prevent against some of leaderboard's more well-known problems. Specifically, cheating prevention and username moderation.
Cheating prevention
It's pretty easy to cheat. All you have to do is look at the network requests that an app is sending, and then artificially send some modified requests of your own. You just replace the "score" field with 999999 and send it off to the cloud!
There are a few automatic checks I could implement on the server to make this slightly less likely to work.
- I could check for obviously too-high scores. I guess it's technically possible for the player to never fall and then get an infinitely increasing score, but there is probably some practical limit I could enforce.
- I could check whether a request is coming in too soon after the previous request. There ought to be at least a delay of 10 seconds or so between when the player finishes two attempts on a given level.
- I could log an event when the player starts a level, and then ensure that any score submission always has a corresponding level-start event and that there was enough time between level-start and level-end to account for the given score.
Username moderation
Anytime you open something up to the public for folks to "anonymously" post in, you're going to get some inappropriate submissions like swear words, references to specific human anatomy, and hate speech.
Moderating content like this is a very hard—maybe impossible—problem to solve automatically. You can't just look at substrings; for example "sass" has a bad word in it, but you should definitely still let people use the word "sass". Also, people are very creative, and can usually come up with all sorts of misspellings that are still obvious to a human but go right past your automated checks. And people will always be able to invent new double meanings for previously PC words.
So, any half-decent content moderation system need so to have a combination of both automated checks as well as humans looking at submissions. At the very least, there ought to be a button that users can press to report inappropriate content.
I decided that that's all way too much work for me to do. I need to spend my time writing code, not hand-censoring creative spellings for male genitalia. I think this leaves me with two options.
Option 1: Lean on a third-party solution
I could require players to log in with some third-party game system, like Google Play Games or Facebook Games, and then just use the already-moderated username (and even avatar) that the player configured through that other system.
I would have to be careful to not use the player's actual name or email address, since that's more sensitive and something that a lot of people won't want to share publicly.
A big problem with this approach is that there is no single game system that all players are already set up with. I could just require this third-party registration as a condition of using any of the cloud features of my game, but that's not as nice for the player.
Option 2: Generate usernames myself
I could just generate random username options, and let the user pick one that they like.
I could be confident that this will produce safe names. But this robs the player of an opportunity for creative expression, and will probably make them feel less connected and invested in their player ID and their positions in leaderboards.
I decided to go this route. To generate names, I found two big lists of words: a list of interesting adjectives and a list animal names. I then went through these lists by hand and removed any words that might have unsafe implications—e.g., "ample", "moist", and those other names for donkeys and roosters.
Authentication also has some big challenges
The number one rule of security is that no security is impenetrable. With enough motivation and time, the attacker can find a way through. All security systems really just serve to make things less convenient or less obvious to the attacker.
Consequently, all of the good online authentication schemes are really convoluted and extremely difficult to wrap your head around if you're trying to implement an authentication system yourself, or even if you're just trying to interface with a third-party OAuth2.0 solution. Which brings us to the number one rule of authentication: don't roll your own authentication. There are always too many edge cases you won't think about. You should always use pre-existing authentication tools if at all possible.
The problem for me is that no pre-existing authentication tools really work with Godot. Pretty much all authentication tools want you to use their libraries in both your server and in your client (in Godot), and no one provides a library for Godot. (I did find one exception, Heroic Lab's Nakama server, but they are way too expensive).
People have tried creating various open-source third-party modules and plugins for Godot that wrap native Android and iOS libraries, so that you can access these official authentication tools from Godot (example, example). However, these never seem to work for both Android and iOS or with the libraries I need.
So I decided I would need to roll my own authentication system, even though I know I'm not supposed to...
What is OAuth?
Before I get into my implementation, I should just mention what OAuth is. OAuth is what let's you sign in with Google on non-Google sites—or with Facebook on non-Facebook sites—or other identity providers.
It's great from the app creator perspective, because they don't need to create their own insecure custom username and password system. And it's great from the user perspective, since they don't have to remember a bunch of different passwords or worry about data leaks from yet another insecure login system.
The only problem is that it can be pretty convoluted to integrate into your app—and there are various different flows to choose from, and this choice just makes it even harder for clueless devs. The flow I implemented goes something like this (sorry this should be a diagram, but I'm lazy):
- The Godot app sends a request to my custom server.
- My server sends back a response with the Google auth URL. It includes some important parameters, like where to redirect afterward, and a CSRF token which will let my server associate the eventual auth data with the original user request.
- The Godot app gets the response and opens up an external browser with the given Google auth URL.
- The user then logs in with Google, and accepts whatever permissions I've configured my server to have access to.
- The Google authentication system then redirects the user (still in the external browser) back to my server, according to the redirect URL I'd specified earlier. Google sends along in the network request the authentication state for the now-logged-in user.
- My server receives this request, stores the authentication state, and associates it with the original user who triggered the auth flow according to the included CSRF token.
- Additionally, my server sends another request to to the Google authentication system in order to swap an authorization code for an access token.
- Then, my server sends back a response to the users browser, with some HTML/JavaScript. At this point, the external browser should close and the user should be navigated back to the Godot app.
Additionally, as the developer, I have to set up a couple other legal things, since I'm now storing user data—specifically, a privacy policy document, and a terms of service document. I found an app that generates these for you, but I suspect I should actually talk to a lawyer if I want documents I feel safe using.
My implementation
I decided to implement my server in Node.js, and use Firebase as my database. I learned all about the google-api-nodejs-client SDK for implementing an OAuth2.0 flow from my server, and the Firebase Admin SDK for accessing the database from my server.
I learned about using JWTs, so that my server can be stateless. Then I learned about Cloud Functions for Firebase, which would let me leverage my statelessness so that my app could scale as needed.
I learned about CSRF tokens, and how to use these so that my server could associate my Godot client with the authentication state returned from Google authentication.
Then I tied it all together, and got a prototype working! 🎉
The straw that broke the camel's back
Ultimately, there was one big problem (besides the fact that I my attempt at tackling this convoluted auth madness was almost certainly full of critical security bugs): I had no way of automatically getting back to the Godot app after the user was done logging in through the external browser.
Universally, other apps solve this problem by using a WebView to show the auth flow from a browser embedded within their app; they then have more control over closing the window after login is done. However, Godot intentionally has no support for WebViews, so I would have to rely on navigation to an external browser app. And browsers have strict limitations on how and when a window can be closed automatically from JavaScript in the webpage, so I have no way of getting back to my app automatically!
The best I can do is print a message telling the user to please now navigate back to my game. At this point, the user hasn't even started playing my game. They aren't hooked. This is just a terrible user experience, and they are likely to give up on my app (and they should!).
Time to give up
At this point, I'd spent a couple weeks digging into this, so it was hard to abandon it. But I don't think I can implement a solution that both provides an acceptable user experience and won't take me another month to figure out.
Even if the auth user experience was acceptable, there was still a lot of work remaining to finish implementing these leaderboards:
- Support for creating new user accounts, generating new usernames, and checking whether usernames already exist.
- Support changing usernames.
- Support logging out or switching users.
- Support data deletion requests.
- All of the GUIs for actually rendering the leaderboards within the app.
- Database structure, and the various client and server functions for storing and retrieving data (bearing in mind that I have to maintain separate lists for leaderboard high-scores and each given user's personal scores).
- Checks for cheating.
- Logic to sync local state with cloud state, each time the user opens the app, or regains network connectivity.
- Cross my fingers that I don't have any security bugs that are too critical!
Not all wasted time though!
My goal for this was to have a re-usable solution that I can also use in future apps. That didn't quite happen. But if I do decide that I have a more worth-while server and authentication need in the future, I at least have some re-usable knowledge. I am pretty confident that I know how I would implement a future server:
- If making mobile apps:
- No OAuth with third-party identity providers.
- Purely roll my own:
- Usernames and passwords.
- JWTs (no CSRF tokens).
- Expiry dates and refresh tokens.
- No navigation with external browsers or embedded WebViews.
- Stateless server.
- Probably use Firebase for database, and “Google Cloud Functions for Firebase” for server.
- If making a web app:
- Can easily implement classical OAuth flow, using officially supported web SDKs.
What will I do instead for now?
For my current game, I think I can simply record anonymized aggregate gameplay analytics, and then use those to show the player their score's percentile relative to all other scores. I think that this will serve the same basic purpose as a leaderboard for creating a compelling experience for the player.
🎉 Cheers!
Comments
Post a Comment