Your question is technically how to complete this using security rules, but as it's a bit of an XY problem, and none of the other possibilities have been ruled out, I'll tackle some of them here as well.
I'll be making a great deal of assumptions, since answering this question actually requires a fully specified set of rules that need to be followed and is really a matter of implementing an entire application (increasing a score is a result of the game logic rules, not a simple math problem).
Total the score at the client
Perhaps the simplest answer to this conundrum is to simply not have a total score. Just grab the list of players and total them manually.
When this might be useful:
- the list of players is hundreds or less
- the player data is appropriately small (not 500k each)
How to do it:
var ref = new Firebase(URL);
function getTotalScore(gameId, callback) {
ref.child('app/games/' + gameId + '/players').once('value', function(playerListSnap) {
var total = 0;
playerListSnap.forEach(function(playerSnap) {
var data = playerSnap.val();
total += data.totalScore || 0;
});
callback(gameId, total);
});
}
Use a privileged worker to update the score
A very sophisticated and also simple approach (because it only requires that the security rules be set to something like ".write": "auth.uid === 'SERVER_PROCESS'"
) would be to use a server process that simply monitors the games and accumulates the totals. This is probably the simplest solution to get right and the easiest to maintain, but has the downside of requiring another working part.
When this might be useful:
- you can spin up a Heroku service or deploy a .js file to webscript.io
- an extra monthly subscription in the $5-$30 range are not a deal-breaker
How to do it:
Obviously, this involves a great deal of application design and there are various levels this has to be accomplished at. Let's focus simply on closing games and tallying the leaderboards, since this is a good example.
Begin by splitting the scoring code out to its own path, such as
/scores_entries/$gameid/$scoreid = < player: ..., score: ... >
/game_scores/$gameid/$playerid = <integer>
Now monitor the games to see when they close:
var rootRef = new Firebase(URL);
var gamesRef = rootRef.child('app/games');
var lbRef = rootRef.child('leaderboards');
gamesRef.on('child_added', watchGame);
gamesRef.child('app/games').on('child_remove', unwatchGame);
function watchGame(snap) {
snap.ref().child('status').on('value', gameStatusChanged);
}
function unwatchGame(snap) {
snap.ref().child('status').off('value', gameStatusChanged);
}
function gameStatusChanged(snap) {
if( snap.val() === 'CLOSED' ) {
unwatchGame(snap);
calculateScores(snap.name());
}
}
function calculateScores(gameId) {
gamesRef.child(gameId).child('users').once('value', function(snap) {
var userScores = {};
snap.forEach(function(ss) {
var score = ss.val() || 0;
userScores[ss.name()] = score;
});
updateLeaderboards(userScores);
});
}
function updateLeaderboards(userScores) {
for(var userId in userScores) {
var score = userScores[userId];
lbRef.child(userId).transaction(function(currentValue) {
return (currentValue||0) + score;
});
}
}
Use an audit path and security rules
This will, of course, be the most sophisticated and difficult of the available choices.
When this might be useful:
- when we refuse to utilize any other strategy involving a server process
- when dreadfully worried about players cheating
- when we have lots of extra time to burn
Obviously, I'm biased against this approach. Primarily because it's very difficult to get right and requires a lot of energy that could be replaced with a small monetary investment.
Getting this right requires scrutiny at each individual write request. There are several obvious points to secure (probably more):
- Writing any game event that includes a score increment
- Writing the total for the game per user
- Writing the game's total to the leaderboard
- Writing each audit record
- Ensuring superfluous games can't be created and modified on the fly just to boost scores
Here are some basic fundamentals to securing each of these points:
- use an audit trail where users can only add (not update or remove) entries
- validate that each audit entry has a priority equal to the current timestamp
- validate that each audit entry contains valid data according to the current game state
- utilize the audit entries when trying to increment running totals
Let's take, for an example, updating the leaderboard securely. We'll assume the following:
- the users' score in the game is valid
- the user has created an audit entry to, say, leaderboard_audit/$userid/$gameid, with a current timestamp as the priority and the score as the value
- each user record exists in the leaderboard ahead of time
- only the user may update their own score
So here's our assumed data structure:
/games/$gameid/users/$userid/score
/leaderboard_audit/$userid/$gameid/score
/leaderboard/$userid = { last_game: $gameid, score: <int> }
Here's how our logic works:
- game score is set at
/games/$gameid/users/$userid/score
- an audit record is created at
/leaderboard_audit/$userid/games_played/$gameid
- the value at
/leaderboard_audit/$userid/last_game
is updated to match $gameid
- the leaderboard is updated by an amount exactly equal to
last_game
's audit record
And here's the actual rules:
{
"rules": {
"leaderboard_audit": {
"$userid": {
"$gameid": {
// newData.exists() ensures records cannot be deleted
".write": "auth.uid === $userid && newData.exists()",
".validate": "
// can only create new records
!data.exists()
// references a valid game
&& root.child('games/' + $gameid).exists()
// has the correct score as the value
&& newData.val() === root.child('games/' + $gameid + '/users/' + auth.uid + '/score').val()
// has a priority equal to the current timestamp
&& newData.getPriority() === now
// is created after the previous last_game or there isn't a last_game
(
!root.child('leaderboard/' + auth.uid + '/last_game').exists() ||
newData.getPriority() > data.parent().child(root.child('leaderboard/' + auth.uid + '/last_game').val()).getPriority()
)
"
}
}
},
"leaderboard": {
"$userid": {
".write": "auth.uid === $userid && newData.exists()",
".validate": "newData.hasChildren(['last_game', 'score'])",
"last_game": {
".validate": "
// must match the last_game entry
newData.val() === root.child('leaderboard_audit/' + auth.uid + '/last_game').val()
// must not be a duplicate
newData.val() !== data.val()
// must be a game created after the current last_game timestamp
(
!data.exists() ||
root.child('leaderboard_audit/' + auth.uid + '/' + data.val()).getPriority()
< root.child('leaderboard_audit/' + auth.uid + '/' + newData.val()).getPriority()
)
"
},
"score": {
".validate": "
// new score is equal to the old score plus the last_game's score
newData.val() === data.val() +
root.child('games/' + newData.parent().child('last_game').val() + '/users/' + auth.uid + '/score').val()
"
}
}
}
}
}