As a Roblox developer, it is currently impossible to detect when a user is joining another server of your game from an alternate device. This can create serious item duplication issues that are out of the developer’s control.
How this affects my game and what I have to do to get around it:
I have a game with a simple inventory system using itemIds and quantity. Users can “Drop” their items onto the ground, which simply removes 1 from the “quantity” of that item in their inventory, and creates a world item which can be “picked up”, which does the opposite, adds 1 to the “quantity” of that itemId in their inventory.
Where Roblox’s kick-instant-rejoin on alt-device system creates issues for games like mine (and this doesn’t only affect my game, basically any game that uses a similar inventory-world item system):
Join any server
Drop items on ground (-x quantity from inventory)
Join another server with the same account, but on a different device
New server has no knowledge of the dropped items, because it loaded in before server #1 was able to update their datastore. Server #1 still has the items on the ground / were picked up by another player and/or alternate account.
Server #2 ultimately overrides server #1’s Datastore updates.
Solutions I can do to solve this:
Every time a player drops an item: update inventory in DataStore - Nope, DataStore limits will prevent us from doing this.
When a player joins a server, tag them as “IsPlaying” in their DataStore. Set IsPlaying to false when saving to DataStore on PlayerRemoving and BindToClose. If their data already states IsPlaying == true on join, :Kick() them from the game. - This is doable, however it forces me to kick the player from my game, when all they’re trying to do is play. This creates a negative UX for non-malicious players who are simply changing devices. This also has the caveat of, well, what happens when DataStores fail and their data is stuck on IsPlaying == true, then they can never join again! I could combat that case with a time based system, maybe only kick within x minutes of IsPlaying == true, yada yada.
Same as #2, however instead of kicking the user, repeatedly check their datastore (:GetAsync) until IsPlaying == false/nil. - This is also doable, however it will also result in hitting DataStore limits much quicker.
For now, I have to settle with solution #2 for now, but it’s an incredible inconvenience for something that, in my opinion, Roblox should help solve.
The proposed change:
When a player joins a game and is detected to already be playing another game on another device (functionality that already exists), yield a reasonable amount of time to let game #1 cleanup/save their data before firing the PlayerAdded event in game #2. I’m unsure of what the amount of time would be required, as DataStore web calls can yield an unexpected amount of time, but I’d say roughly 5-10 seconds would likely suffice for this issue.
Myself and many other developers would benefit greatly from a change such as this, even if they aren’t aware of this type of issue.
We fight similar datastores issues with World // Zero because you can teleport between places, and the issue of people trying to duplicate items using a friend to trade. Our fix was to not load their data in the main menu for 10 seconds (while they’re loading in) to allow any old servers some time to save.
We were able to ensure that teleporting between places would use the new data upon arrival, by sending a version with their data and waiting for it to match. But we can’t do this for the main menu and I’m afraid that people still see (or abuse) rollbacks when datastores are performing slowly.
Waiting x seconds when a player joins a game could work, however again, degrades UX. Especially for a game where you are being teleported often or without a main menu, where you want to join and get into the gameplay as soon as possible. There’s lots of methods around the issue at hand, but ultimately I think Roblox should assist in a solution.
This concurrency sounds like a similar problem that can occur when multithreading. Luckily locking the subject is a tried and true solution.
The simplest method to this would be to mark the user’s data as locked while it is saving. If the user’s data is currently locked while loading, you would need to yield until the data becomes unlocked.
The flow would look something like this:
Saving:
User makes a change which dirties their data’s state
Time passes for an autosave or the player leaves the game
Immediately write to a datastore for a player that the save is in progress by flipping a single key’s value from false to true. This is the lock value we will use. (Note: this is important that you dedicate an entire key to this lock value rather than bundling it into the entire data state. It should also be faster to write and read from)
Yield for locked value to return as a success. Once you’ve got the value successfully locked, save the profile’s data state into a separate key. This write will likely take longer than flipping the locked value depending on how much state you need to save.
Once the player’s state is written to, write to the original locked key and write it false (or delete it). This will allow other servers to load the data again.
Loading:
The user joins the game
Immediately read the currently value of the locked datastore key. If the key is true (locked) then we need to wait for this value to change back to false. We can manually poll this or use UpdateAsync.
When the lock is no longer true, load the player’s state as needed.
Making requests onto two keys instead of one will increase some latency when writing and reading, though the additional overhead of writing to lock and unlock shouldn’t have much of an impact to the user unless they were loading after recently (as in the last 10 seconds) saving their game since usually we save in the background or after the player leaves.
In addition, it takes out the guesswork of when to load the data meaning your users only wait as long as they need to assuming every other server follows this lock-save-unlock policy it should work.
Edit: Though it also occurred to me, there could be a case given certain network or datastore conditions that could cause the lock value to stay locked “forever”
To prevent this, it may be better to save a timestamp with the lock value when locking it so you know when to reset the lock value in case its been stale for a while.
Adding on more data storage requests sounds scary to me, they’re already infamously inconsistent.
Would locking the data store be a task best equipped for ephemeral data stores, which are “On Track” according to the roadmap? As in, store the timestamp or whatnot to an ephemeral data store, as it doesn’t matter if they persist or not?
Here’s a video of people duplicating items in Swordburst 2. Removed this URL because the video isn’t helpful & quite toxic but the instructions are:
They do it by rejoining on another device, and Roblox does not kick them from the previous game fast enough. The lock approach does not solve this issue because they are able to rejoin the game before the old game can set any keys - including the lock.
Any instance where I’m grabbing out of date data, the lock would be out of date too. So I don’t see how I can solve these instances using datastores.
If data is dirty, send messages to servers through MessagingService with timestamp of departure (or read Data argument’s Sent value on receiving servers, whichever is most effective)
Carry on with what @be_nj suggested by locking the users data with a separate key
All servers:
Subscribe to topic “PlayerRejoin” (for example)
Store data retrieved by UserId (departure timestamp) for a certain time before deleting. (Overwrite pre-existing entries)
Player joins another server on same or different device:
First check the value of the locked datastore key, if it’s locked then skip steps 2-3 and yield until the value becomes false. By checking the datastore key first, we can ensure that regular players aren’t subjected to an unnecessary wait time produced by step 3 if their data was dirty as they left.
Check data received from above mentioned message topic to see if the player has an active departure entry
If data was received for player then assume that the player’s data is locked and yield for a certain period of time (5-10 seconds maybe?)
Poll locked datastore key until false (implement a limit of some sort in case the key get’s stuck as be_nj noted)
If lock is no longer active, load player’s state as needed and remove departure data.
One primary weakness to this is that the MessagingService’s delivery of messages is not guaranteed. But, I believe this still has a chance of reducing player’s ability to abuse a trading system considerably.
Another concern is that it’s still possible for players to be subjected to an unnecessary wait but the window should be fairly small for that possibility.