Reputation: 3190
I have a question about a situation I am in currently which I have a solution for but am not quite sure if it 100% solves the issue at hand as I do not have tests written that could validate my solution.
I would love your oppinion on the matter and maybe a suggestion of a more elegant solution or possibly even a way to avoid the issue completely.
Here it is:
I am making a game where you may create or join open rooms/games.
There is a gamelist in the UI and when you click a game you attempt to join that game.
Each game has a bet (amount of credit that you win or lose) that the creator set which anyone joining must match.
On the serverside, before I let the player actually join the room, I must validate that his credit balance is sufficient to match the bet of the game he is joining. This will be via an API call.
Now, if two players join the game at once, lets say the validation of the first player joining takes 3 seconds but the validation of the second only 1 second.
Since rooms are 1 vs 1 I must not let a player join if someone else already did.
I can do this simply by checking if theres a player in the game already:
// game already full
if (game.p2) {
return socket.emit("join_game_reply", {
err: "Someone else already joined."
})
}
But, the issue at hand is, after that check, I must validate the balance.
So we get something like this:
socket.on("join_game", data => {
const game = openGames[data.gameId}
// game already full
if (game.p2) {
return socket.emit("join_game_reply", {
err: "Someone else already joined."
})
}
// check if users balance is sufficient to match bet of room creator
verifyUserBalance(socket.player, game.bet)
.then(sufficient => {
if(sufficient){
// join game
game.p2 = socket.player
}
})
})
The issue here:
What if at the time playerX clicks join
the game is open, validation starts but while validating playerY joins and finishes validation before playerX and therefore is set as game.p2
. Validation of playerX finished shortly after and the server then continues to set game.p2 to playerX, leaving playerY with a UI state of ingame even though on the server he is not anymore.
The solution I have is to literally just do the check again after validation:
socket.on("join_game", data => {
const game = openGames[data.gameId}
// game already full
if (game.p2) {
return socket.emit("join_game_reply", {
err: "Someone else already joined."
})
}
// check if users balance is sufficient to match bet of room creator
verifyUserBalance(socket.player, game.bet)
.then(sufficient => {
if(sufficient){
// join game
if (game.p2) {
return socket.emit("join_game_reply", {
err: "Someone else already joined."
})
game.p2 = socket.player
}
}
})
})
The reason I think this works is because nodeJS is single threaded and I can therefore make sure that after validating I only let players join if no one else joined in the meantime.
After writing this up I actually feel pretty confident that it will work so please let me in on my mistakes if you see any! Thanks a lot for taking the time!
Upvotes: 1
Views: 508
Reputation: 707326
You need to make "verify and join" an atomic operation on the server so nobody else can cause a race condition. There are many different ways to approach a solution. The best solution would only impact that particular game, not impacting the processing of joining other games.
Here's one idea:
Create a means of "provisionally joining a game". This will essentially reserve your spot in the game while you then check to see if the user verifies for the game. This prevents anyone else from joining a game you were first at and are in the process of verifying.
When someone else comes in to provisionally join a game, but the game already has a provisional user, the join function can return a promise that is not yet resolved. If the previous provisional join verifies and finishes, then this promise will reject because the game is already full. If the other provisional join fails to verify, then the first one to request a provisional join will resolve and it can then go on with the verification process.
If this second user verifies correctly and converts to a completed join to the game, it will reject any other waiting promises for other provisional joins to this game. If it fails to verify, then it goes back to step 2 and the next one waiting gets a chance.
In this way, each game essentially has a queue of users waiting to get into the game. The queue hangs around as long as the game isn't full of verified users so whenever anyone doesn't verify, the next one in the queue gets a shot at joining.
For performance and user-experience reasons, you may want to implement a timeout on waiting in the queue and you may want to limit how many users can be in the queue (probably no point in allowing 100 users to be in the queue since it's unlikely they will all fail to verify).
It's important to understand that the verify and join needs to be all implemented on the server because that's the only way you can assure the integrity of the process and control it to avoid race conditions.
Upvotes: 0
Reputation: 1256
Your code will work, but I think this is bootstrapping for short-term and you will have to change it in the mid-term.
It will work
A. if you have only one server.
B. If your server is not crashing
C. If you have just one synchronous action (here game.p2 = socket.player
)
To scale up your infra, I'm afraid it won't work.
You should not use nodejs variables (as openGames
) to store data but retrieve them from a cache database (as redis). This redis database will be you single source of truth.
The same kind of problems will happen if your server crash (for any reason, like full disk ...) You will lose all your data stored in nodejs variables.
If you want to add one action (like putting the bet amount in escrow) in your workflow, you will need to catch the failure if this action (and the failure of the room joining) and guarantee that there is a all-or-nothing mechanism (escrow+joining or nothing).
You can manage it in your code but it will become quite complex.
When dealing with money + actions, I think you should use transactions features of databases. I would use for example Redis Transactions.
Upvotes: 2