Implementing Player Data and Purchasing Systems

Implementing Player Data and Purchasing Systems

Background

Roblox provides a set of APIs to interface with data stores via DataStoreService. The most common use case for these APIs is for saving, loading, and replicating player data. That is, data associated with the player’s progress, purchases, and other session characteristics that persists between individual play sessions.

Most experiences on Roblox use these APIs to implement some form of a player data system. These implementations differ in their approach, but generally seek to solve the same set of issues.

Common Problems

Below are some of the most common problems player data systems attempt to solve:

  • In Memory Access: DataStoreService requests make web requests that operate asynchronously and are subject to rate limits. This is appropriate for an initial load at the start of the session, but not for high frequency read and write operations during the normal course of gameplay. Most developers’ player data systems store this data in-memory on the Roblox server, limiting DataStoreService requests to the following scenarios:

    • Initial read at the start of a session

    • Final write at the end of the session

    • Periodic writes at an interval to mitigate the scenario where the final write fails

    • Writes to ensure data is saved while processing a purchase

  • Efficient Storage: Storing all of a player’s session data in a single table lets you update multiple values atomically and handle the same amount of data in fewer requests. It also removes the risk of inter-value desynchronization and makes rollbacks easier to reason around.

    Some developers also implement custom serialization to compress large data structures (typically to save in-game user-generated content).

  • Replication: The client needs regular access to a player’s data (for example, to update the UI). A generic approach to replicating player data to the client lets you transmit this information without having to create bespoke replication systems for each component of data. Developers often want the option to be selective about what is and isn’t replicated to the client.

  • Error Handling: When DataStores cannot be accessed, most solutions will implement a retrying mechanism and a fallback to ‘default’ data. Special care is needed to ensure fallback data doesn’t later overwrite ‘real’ data, and that this is communicated to the player appropriately.

  • Retries: When data stores are inaccessible, most solutions implement a retry mechanism and a fallback to default data. Take special care to ensure that fallback data doesn’t later overwrite “real” data, and communicate the situation to the player appropriately.

  • Session Locking: If a single player’s data is loaded and in-memory on multiple servers, issues can occur in which one server saves out-of-date information. This can lead to data loss and common item duplication loopholes.

  • Atomic Purchase Handling: Verify, award, and record purchases atomically to prevent items from being lost or awarded multiple times.

Sample Code

GET THE CODE

This code is provided for reference purposes only and has not proven itself over a long period of time in a popular experience. It exists so that you can examine its approach to solving various problems and apply them to your own player data systems. Don’t use this code in your experience as-is without extensive testing.

Roblox has reference code to assist you with designing and building player data systems. The remainder of this page examines background, implementation details, and general caveats.

Architecture

This high-level diagram illustrates the key systems in the sample and how they interface with code in the rest of the experience.

Retries

Class: DataStoreWrapper

Background

As DataStoreService makes web requests under the hood, its requests are not guaranteed to succeed. When this happens, the DataStore methods throw errors, allowing you to handle them.

A common “gotcha” can occur if you attempt to handle data store failures like this:

local function retrySetAsync(dataStore, key, value)
   for i = 1, MAX_ATTEMPTS do
       local success, result = pcall(dataStore.SetAsync, dataStore, key, value)

       if success then
           break
       end

       task.wait(TIME_BETWEEN_ATTEMPTS)
   end
end

While this is a perfectly valid retry mechanism for a generic function, it is not suitable for DataStoreService requests because it does not guarantee the order in which requests are made. Preserving the order of requests is important for DataStoreService requests because they interact with state. Consider the following scenario:

  1. Request A is made to set the value of key K to 1.

  2. The request fails, so a retry is scheduled to run in 2 seconds.

  3. Before the retry occurs, request B sets the value of K to 2, but the retry of request A immediately overwrites this value and sets K to 1.

Even though UpdateAsync operates on the latest version of the key’s value, UpdateAsync requests must still be processed in order to avoid invalid transient states (for example, a purchase subtracts coins before a coin addition gets processed, resulting in negative coins).

Our player data system uses a new class, DataStoreWrapper, which provides yielding retries that are guaranteed to be processed in order per key.

Approach

DataStoreWrapper provides methods corresponding to the DataStore methods: DataStore:GetAsync(), DataStore:SetAsync(), DataStore:UpdateAsync() and DataStore:RemoveAsync().

These methods, when called:

  1. Add the request to a queue. Each key has its own queue, where requests are processed in order and in series. The requesting thread yields until the request has completed.

    This functionality is based on the ThreadQueue class, which is a coroutine-based task scheduler and rate limiter. Rather than returning a promise, ThreadQueue yields the current thread until the operation is complete and throws an error if it fails. This is more consistent with idiomatic asynchronous Lua patterns.

  2. If a request fails, it retries with a configurable exponential backoff. These retries form part of the callback submitted to the ThreadQueue, so they are guaranteed to complete before the next request in the queue for this key begins.

  3. When a request is completed, the request method returns with the success, result pattern

DataStoreWrapper also exposes methods to get the queue length for a given key and clear out stale requests. The latter option is particularly useful in scenarios when the server is shutting down and there is no time to process any but the most recent requests.

Caveats

DataStoreWrapper follows the principle that, outside of extreme scenarios, every data store request should be allowed to complete (successfully or otherwise), even if a more recent request makes it redundant. When a new request occurs, stale requests aren’t removed from the queue, but are instead allowed to complete before the new request is started. The rationale for this is rooted in this module’s applicability as a generic data store utility rather than a specific tool for player data, and is as follows:

  1. It’s hard to decide on an intuitive set of rules for when a request is safe to remove from the queue. Consider the following queue:

    Value=0, SetAsync(1), GetAsync(), SetAsync(2)

    The expected behavior is that GetAsync() would return 1, but if we remove the SetAsync() request from the queue due to it being made redundant by the most recent one, it would return 0.

    The logical progression is that when a new write request is added, only prune stale requests as far back as the most recent read request. UpdateAsync(), by far the most common operation (and the only one used by this system), can both read and write, so it would be difficult to reconcile within this design this without adding extra complexity.

    DataStoreWrapper could require you to specify whether an UpdateAsync() request was permitted to read and/or write, but it would have no applicability to our player data system, where this cannot be determined ahead of time due to the session locking mechanism (covered in more detail later).

  2. Once removed from the queue, it’s hard to decide on an intuitive rule for how this should be handled. When a DataStoreWrapper request is made, the current thread is yielded until it is completed. If we removed stale requests from the queue, we would have to decide whether to return false, “Removed from queue” or to never return and discard the active thread. Both approaches come with their own drawbacks and offload additional complexity onto the consumer.

Ultimately, our view is that the simple approach (processing every request) is preferable here and creates a clearer environment to navigate in when approaching complex issues like session locking. The only exception to this is during DataModel:BindToClose(), where clearing the queue becomes necessary to save all users’ data in time and the value individual function calls return is no longer an ongoing concern. To account for this, we expose a skipAllQueuesToLastEnqueued method. For more context, see Player Data.

Session Locking

Class: SessionLockedDataStoreWrapper

Background

Player data is stored in memory on the server and is only read from and written to the underlying data stores when necessary. You can read and update in-memory player data instantly without needing web requests and avoid exceeding DataStoreService limits.

For this model to work as intended, it is imperative that no more than one server is able to load a player’s data into memory from the DataStore at the same time.

For example, if server A loads a player’s data, server B can’t load that data until server A releases its lock on it during a final save. Without a locking mechanism, server B could load out-of-date player data from the data store before server A has a chance to save the more recent version that it has in memory. Then if server A saves its newer data after server B loads the out-of-date data, server B would overwrite that newer data during its next save.

Even though Roblox only allows a client to be connected to one server at a time, you can’t assume that data from one session is always saved before the next session starts. Consider the following scenarios that can occur when a player leaves server A:

  1. Server A makes a DataStore request to save their data, but the request fails and requires several retries to successfully complete. During the retry period, the player joins server B.

  2. Server A makes too many UpdateAsync() calls to the same key and gets throttled. The final save request is placed in a queue. While the request is in the queue, the player joins server B.

  3. On server A, some code connected to the PlayerRemoving event yields before the player’s data is saved. Before this operation completes, the player joins server B.

  4. The performance of server A has degraded to the point that the final save is delayed until after the player joins server B.

These scenarios should be rare, but they do occur, particularly in situations where a player disconnects from one server and connects to another in rapid succession (for example, while teleporting). Some malicious users might even attempt to abuse this behavior to complete actions without them persisting. This can be particularly impactful in games that allow players to trade and is a common source of item duplication exploits.

Session locking addresses this vulnerability by ensuring that when a player’s DataStore key is first read by the server, the server atomically writes a lock to the key’s metadata inside the same UpdateAsync() call. If this lock value is present when any other server attempts to read or write the key, the server does not proceed.

Approach

SessionLockedDataStoreWrapper is a meta-wrapper around the DataStoreWrapper class. DataStoreWrapper provides queueing and retrying functionality, which SessionLockedDataStoreWrapper supplements with session locking.

SessionLockedDataStoreWrapper passes every DataStore request—regardless of whether it is GetAsync, SetAsync or UpdateAsync—through UpdateAsync. This is because UpdateAsync allows a key to be both read and written to atomically. It is also possible to abandon the write based on the value read by returning nil in the transformation callback.

The transformation function passed into UpdateAsync for each request performs the following operations:

  1. Verifies the key is safe to access, abandoning the operation if it is not. “Safe to access” means:
  • The key’s metadata object does not include an unrecognized LockId value that was last updated less than the lock expiry time ago. This accounts for respecting a lock placed by another server and for ignoring that lock if it expired.

  • If this server has placed its own LockId value in the key’s metadata previously, then this value is still in the key’s metadata. This accounts for the situation where another server has taken over this server’s lock (by expiry or by force) and later released it. Alternatively phrased, even if LockId is nil, another server could still have replaced and removed a lock in the time since you locked the key.

  1. UpdateAsync performs the DataStore operation the consumer of SessionLockedDataStoreWrapper requested. For example, GetAsync() translates to function(value) return value end.

  2. Depending on the parameters passed into the request, UpdateAsync either locks or unlocks the key:

  3. If the key is to be locked, UpdateAsync sets the LockId in the key’s metadata to a GUID. This GUID is stored in-memory on the server so it can be verified the next time it accesses the key. If the server already has a lock on this key, it makes no changes. It also schedules a task to warn you if you don’t access the key again to maintain the lock within the lock’s expiration time.

  4. If the key is to be unlocked, UpdateAsync removes the LockId in the key’s metadata.

A custom retry handler is passed into the underlying DataStoreWrapper so that the operation is retried if it was aborted at step 1 due to the session being locked.

A custom error message is also returned to the consumer, allowing the player data system to report an alternative error in the case of session locking to the client.

Caveats

The session locking regime relies on a server always releasing its lock on a key when it is done with it. This should always happen through an instruction to unlock the key as part of the final write in PlayerRemoving or BindToClose().

However, the unlock can fail in certain situations. For example:

  • The server crashed or DataStoreService was inoperable for all attempts to access the key.

  • Due to an error in logic or similar bug, the instruction to unlock the key was not made.

To maintain the lock on a key, you must regularly access it for as long as it is loaded in memory. This would normally be done as part of the auto-save loop running in the background in most player data systems, but this system also exposes a refreshLockAsync method if you need to do it manually.

If the lock expiry time has been exceeded without the lock being updated, then any server is free to take over the lock. If a different server takes the lock, attempts by the current server to read or write the key fail unless it establishes a new lock.

Developer Product Processing

Singleton: ReceiptHandler

Background

The ProcessReceipt callback performs the critical job of determining when to finalize a purchase. ProcessReceipt is called in very specific scenarios. For its set of guarantees, see MarketplaceService.ProcessReceipt.

Although the definition of “handling” a purchase can differ between experiences, we use the following criteria

  1. The purchase has not previously been handled.

  2. The purchase is reflected in the current session.

  3. The purchase has been saved to a DataStore.

    Every purchase, even one-time consumables, should be reflected in the DataStore so users’ purchase history is included with their session data.

This requires conducting the following operations before returning PurchaseGranted:

  1. Verify the PurchaseId has not already been recorded as handled.

  2. Award the purchase in the player’s in-memory player data.

  3. Record the PurchaseId as handled in the player’s in-memory player data.

  4. Write the player’s in-memory player data to the DataStore.

Session locking simplifies this flow, as you no longer need to worry about the following scenarios:

  • The in-memory player data in the current server potentially being out-of-date, requiring you to fetch the latest value from the DataStore before verifying the PurchaseId history

  • The callback for the same purchase running in another server, requiring you to both read and write the PurchaseId history and save the updated player data with the purchase reflected atomically to prevent race conditions

Session locking guarantees that, if an attempt to write to the player’s DataStore is successful, no other server has successfully read or written to the player’s DataStore between the data being loaded and saved in this server. In short, the in-memory player data in this server is the most up-to-date version available. There are some caveats, but they do not impact this behavior.

Approach

The comments in ReceiptProcessor outline the approach:

  1. Verify that the player’s data is currently loaded on this server and that it loaded without any errors.

    Because this system uses session locking, this check also verifies that the in-memory data is the most up-to-date version.

    If the player’s data hasn’t loaded yet (which is expected when a player joins a game), wait for the player’s data to load. The system also listens for the player leaving the game before their data loads, as it should not yield indefinitely and block this callback from being invoked again on this server for this purchase if the player rejoins.

  2. Verify the PurchaseId is not already recorded as processed in the player data.

    Due to session locking, the array of PurchaseIds the system has in memory is the most up-to-date version. If the PurchaseId is recorded as processed and reflected in a value that has been loaded to or saved to the DataStore, return PurchaseGranted. If it is recorded as processed, but not reflected in the DataStore, return NotProcessedYet.

  3. Update the Player Data locally in this server to “award” the purchase.

    ReceiptProcessor takes a generic callback approach and assigns a different callback for each DeveloperProductId.

    As soon as this step completes, the player receives access to the purchase in the current session. This means that if the player’s data fails to save in step 5, the player receives the purchase for free for the duration of the session.

  4. Update the player data locally in this server to store the PurchaseId.

  5. Submit a request to save the in-memory data to the DataStore, returning PurchaseGranted if the request is successful. If not, return NotProcessedYet.

    If this save request is not successful, a later request to save the player’s in-memory session data could still succeed. During the next ProcessReceipt call, step 2 handles this situation and returns PurchaseGranted.

Player Data

Singletons: PlayerData.Server, PlayerData.Client

Background

Modules that provide an interface for game code to synchronously read and write player session data are common in Roblox experiences. This section covers PlayerData.Server and PlayerData.Client.

Approach

PlayerData.Server and PlayerData.Client handle the following:

  1. Loading the player’s data into memory, including handling cases in which it fails to load

  2. Providing an interface for server code to query and change the player data

  3. Replicating changes in the player’s data to the client so that client code can access it

  4. Replicating loading and/or saving errors to the client so that it can show error dialogs

  5. Saving the player’s data periodically, when the player leaves, and when the server shuts down

Loading Player Data

  1. SessionLockedDataStoreWrapper makes a getAsync request to the data store.

    If this request fails, the default data is used and the profile is marked as “errored” to ensure it is not written to the data store later.

    An alternative option is to kick the player, but we recommend letting the player play with default data and clear messaging as to what occurred rather than removing them from the experience.

  2. An initial payload is sent to PlayerDataClient containing the loaded data and the error status (if any).

  3. Any threads yielded using waitForDataLoadAsync for the player are resumed.

Providing an Interface for Server Code

  • PlayerDataServer is a singleton that can be required and accessed by any server code running in the same environment.

  • Player data is organized into a dictionary of keys and values. You can manipulate these values on the server using the setValue, getValue, updateValue and removeValue methods. These methods all operate synchronously without yielding.

  • The hasLoaded and waitForDataLoadAsync methods are available to ensure the data has loaded before you access it. We recommend doing this once during a loading screen before other systems are started to avoid having to check for load errors before every interaction with data on the client.

  • A hasErrored method can query if the player’s initial load failed, causing them to use default data. Check this method before allowing the player to make any purchases, as purchases cannot be saved to data without a successful load.

  • A playerDataUpdated signal fires with the player, key, and value whenever a player’s data is changed. Individual systems can subscribe to this.

Replicating Changes to the Client

  • Any change to player data in PlayerDataServer is replicated to PlayerDataClient, unless that key was marked as private using setValueAsPrivate

    • setValueAsPrivate is used to denote keys that should not be sent to the client
  • PlayerDataClient includes a method to get the value of a key (get) and a signal that fires when it is updated (updated). A hasLoaded method and a loaded signal are also included, so the client can wait for data to load & replicate before starting its systems

  • PlayerDataClient is a singleton that can be required and accessed by any client code running in the same environment

Replicating Errors to the Client

  • Error statuses encountered when saving or loading player data are replicated to PlayerDataClient.

  • Access this information with the getLoadError and getSaveError methods, along with the loaded and saved signals.

  • There are two types of errors: DataStoreError (the DataStoreService request failed) and SessionLocked (see Session Locking).

  • Use these events to disable client purchase prompts and implement warning dialogs. This image shows an example dialog:

    A screenshot of an example warning that could be shown when player data fails to load

Saving Player Data

  1. When the player leaves the game, the system takes the following steps:

    1. Check if it is safe to write the player’s data to the data store. Scenarios where it would be unsafe include the player’s data failing to load or still undergoing loading.

    2. Make a request through the SessionLockedDataStoreWrapper to write the current in-memory data value to the data store and remove the session lock once complete.

    3. Clears the player’s data (and other variables such as metadata and error statuses) from server memory.

  2. On a periodic loop, the server writes each player’s data to the data store (provided it is safe to save). This welcome redundancy mitigates loss in case of a server crash and is also necessary to maintain the session lock.

  3. When a request to shutdown the server is received, the following occurs in a BindToClose callback:

    1. A request is made to save each player’s data in the server, following the process normally gone through when a player leaves the server. These requests are made in parallel, as BindToClose callbacks only have 30 seconds to complete.

    2. To expedite the saves, all other requests in each key’s queue are cleared from the underlying DataStoreWrapper (see Retries).

    3. The callback doesn’t return until all requests have completed.

64 Likes

I’ve semi-analysed the this package by reading the documentation and some of the scripts. Everything seems straight-forward and the package is really modular; but one thing in particular struck me: it uses remote events to replicate player data instead of using conventional instance replication (like NumberValues, StringValues, BoolValues).

I’d really like to know the incentive of this decision, because the module needs extra code to set and track changes to the values, and it could be greatly simplified by using value instances. And i believe ping could be affected significantly by constantly firing RemoteEvents when a single data changes.

4 Likes

Using NumberValues, StringValues, BoolValues, etc. inherently limits the way that you organize your data. For example, how would you go about saving a player’s inventory (assume they can own many different types and quantities of items). How would you then efficiently go about replicating that data to the player without using a RemoteEvent?

4 Likes

You can still represent collections of items - such as an inventory - using Folders.
In my player data system, i have a schema which represents the tree structure. And upon player data initialization, it creates corresponding Instances for the data.

Example
{
				{
	DataName = "Totals",
	DataType = "Table",
	Children = {
		{
			DataName = "Lifetime",
			DataType = "Table",
			Children = {
				{
					DataName = "Money",
					DataType = "String",
					DefaultValue = "0"
				},
				{
					DataName = "Gems",
					DataType = "Number",
					DefaultValue = 0
				}
			}
		},
		{
			DataName = "ThisPrestige",
			DataType = "Table",
			Children = {
				{
					DataName = "Money",
					DataType = "String",
					DefaultValue = "0"
				},
			}
		},
	}
}

In the Example above, upon player data initialization, when the DataType is “Table” it creates a Folder, and similarly when the DataType is “Number” it creates a NumberValue.

This allows me to still represent collections of values (a hierarchy) without resorting to using remote events for replication.

And i’m pretty sure the designer of this system knew this, so there must be some other (perhaps compelling) reason why the designer made the system uses tables to store player data in memory instead of Instances.

4 Likes

Creating instances for each player, especially for complex table layouts is not efficient. Doing so also voids the possibility of selective replication, which is very simple to achieve with RemoteEvents.

Where specifically is your aversion to using RemoteEvents coming from?

4 Likes

Very cool! I am currently looking through the code to learn stuff so I can build my own datastore system. However, I cannot seem to understand why, in threadQueue, the method submitAsync is called just that. Because, clearly it yields. If it has async in the name it shouldn’t yield right? Isn’t that what async means?

Another question, in the constructor (new-method) there is an optional parameter called enableConcurrecy. When assigning self._enableConcurrency the code is as follows:
image
Is this not unclear? I think (however, am unsure) that _enableConcurrency = enableConcurrency or false does the same thing. If no, why?

I have looked thorugh this Programming in Lua : 3.3

3 Likes

‘Async’ as a suffix is used for yielding methods throughout Roblox’s APIs. I think the intention is that you should probably be asynchronously calling this method. You are correct though, it basically means it yields.

See DataStoreService ListDataStoresAsync method for example.

3 Likes

Putting all data in a table, always made me question whether it will hit the data store limits one day or not.

3 Likes

Hi, thanks for your question!

The main reason we opted for formal APIs versus relying on the DM for storage / replication are:

  • Instances in the DataModel can be created / deleted / changed by any Script. This is helpful in some ways, but it means the player data system would have to account for any change other scripts may make to your folder / value structure. By separating the data this system operates on from the APIs to change it, this breaks the module’s encapsulation.
  • This is a minor point, but if we store the data in the DataModel it will replicate to every client, rather than that specific client. There are hacky ways around this (like adding Instances to PlayerGuis), but we’d prefer not to rely on them.
9 Likes

Hi! Good question. The answer for this one may seem a bit unintuitive, but I promise it makes sense!

In Roblox, there’s a convention to suffix methods with ‘async’ if the event yields. By ‘yields’ we mean, the method defers to the task scheduler before it returns allowing the task scheduler to execute other code.

A synchronous method by comparison, does not defer to the task scheduler and no other code can run until it returns. This is how most APIs in Roblox work, which is why most Roblox APIs that do yield are suffixed with Async. Unfortunately, there are loads of APIs that do not conform with this (think WaitForChild).

10 Likes

At the beginning of ReceiptProcessor._processReceiptAsync, there is a check to make sure the player hasn’t left the game.

-- We do not want to save player data if the player is not currently in the server
local player = Players:GetPlayerByUserId(playerId)
if not player then
	return Enum.ProductPurchaseDecision.NotProcessedYet
end

According to the docs:

The player needs to be in the server for the callback to be invoked, but does not need to be in the server for the result of the callback to be recorded on the backend.

I’m wondering if the docs are incorrect?

4 Likes

It’s a shame that all of Roblox’s APIs don’t conform to their own standards as I’ve had a hard time explaining Roblox conventions to other programmers who tend to kick back because of the inconsistencies or purely because they’ve not noticed them (probably due to those inconsistencies, and rightly so, I suppose?)

2 Likes

I have a hard time following, my background in saving things online is best described as nonexistent. I already had scripts doing data stores but for some reason it started erroring yesterday and now I can’t fix it. So my question is if I only want to solve my problem and not really want to take the time to know more about data stores, is this thread worthy of finishing or no?

Edit: have read halfway, I do not get anything at all, what even are wrappers and coroutine TT

1 Like

In ReceiptProcessor, if the purchase id is in the data, it checks to see if it’s in the latest saved data. Isn’t this unnecessary? According to the docs:

There is no time-based “retry” mechanism, meaning that if a Enum.ProductPurchaseDecision enum of NotProcessedYet is returned for a purchase, it will not try again on the same server unless the player successfully initiates another developer product purchase (same product or another product) or re-joins the server.

Wouldn’t this mean that this if statement:

if table.find(receiptsProcessed, purchaseId) then

won’t ever pass in a situation where the purchase id is added locally but not saved?

1 Like

you are a bit confusing at explaining these topics. I also find it annoying you don’t show code examples, leaving me to guess whether you are referring to services or concepts.

For example you mentioned “DataStoreWrappers.” Is this a service? what is a datastorewrapper in coding. How do I code it?

you also show this. HOW DO I CODE THIS? I don’t know what threads are. Why don’t you explain it or link areas that do?

I have been coding a while, but you filled this post with datastore jargon. When you say thread do you want me to make a function and use task.spawn()? what is going on?

1 Like

That’s an unfortunate consequence of Roblox taking backwards compatibility seriously, we don’t have an opportunity to clean up past decisions realigning them with our contemporary design choices.

I can say that the current naming / style standards are strictly upheld. For example, relevant here, any new yielding API added now must have Async in the name.

6 Likes

Completely understand that of course. It’s good to hear that it’s something that’s consciously upheld with new work though.

Thank you for taking your time in writing this article. I really appreciate this resource! I’ve read the entire post several times, and now I’m confident that my datastore and product purchase handling systems are (relatively) safe.

1 Like

I was wondering if anyone is using this? I use it in my game, but he often user data fails to load.
Content of the reminder:“Error Loading player data:SessionLockError”

1 Like

Oh dear! This sounds like sessions are not being released correctly - have you made any changes to these scripts?

2 Likes