Scripting Intermediate Level Tutorial

Scripting Intermediate Level Tutorial

(this tutorial is a work in progress)

PREREQUISITES: You should have a basic understanding of scripting in Core and you should know your way around the Core editor

Table of contents:
I. COMMUNICATING BETWEEN SCRIPTS IN THE SAME CONTEXT

II. COMMUNICATING BETWEEN SCRIPTS IN DIFFERENT CONTEXTS

1. BROADCASTING EVENTS

2. SETTING A DYNAMIC CUSTOM PROPERTY

3. USING PRIVATE NETWORKED DATA

III. USING PERSISTENT DATA STORAGE

INTRODUCTION

During my time in Core, I have noticed that the same questions are asked again and again, coming from people who have a basic but solid understanding of scripting in Core. These people know how to code, but once they have to actually deal with Core and all the features of a multiplayer game engine, they face all kinds of problems. I have been in the same situation as the people asking these questions, and if you're reading this, then chances are you're too! Once we want to create something a little bit more complex, these questions inevitably arise: how do I use a variable in script B that was created in script A? How can I call a function from another script? How do I communicate to a server script from a client script and vice versa? How do I save and load persistent data? How can I make templates and spawn them? And many more..

In this tutorial, I will address some of these problems in the hope that it will enable you to move up a gear in your projects.

Note that when in this tutorial it is said "server script", it means a script that sits in the hierarchy, in what is unofficially called the "default context", or a script that resides inside a server context folder.

I. COMMUNICATING BETWEEN SCRIPTS IN THE SAME CONTEXT

How to communicate between scripts is one of the questions that have been asked the most on the Core discord. Depending on the architecture of your project, you might need to use a variable or call a function that was created in another script. Speaking of architecture, sometimes the best solution is to actually change your architecture so that the need to communicate between script disappears. But in the case that you can't or don't want to change your architecture, I will address several ways to communicate between scripts.

a. serverUserData and clientUserData:

One way I personally use the most is serverUserData and clientUserData. They both are simple tables in which you can store anything you want. As the names suggest, serverUserData is for server side only and clientUserData is for client side only. And as the names don't suggest, they are available in all the scripts of the same context. They are in effect global tables ready for you to exploit. The way to use them is simple: you attach them to any object you want. So let's take the example of a cube and let's say that in your game, every time the cube changes size, you need to remember what the previous size was:

myCube.serverUserData.previousSize = myCube:GetWorldScale() --we save the current size before changing it
myCube:SetWorldScale(Vector3.New(5, 16, 31))--then we change the size

Now from any other server script where you need to recall the previous size of that same cube, you just have to retrieve it that way:

local previousSize = thatSameCube.serverUserData.previousSize

Or if you want to set the original size back directly:

thatSameCube:SetWorldScale(thatSameCube.serverUserData.previousSize)

You can of course print(thatSameCube.serverUserData.previousSize) from any other server script:

Platform-Win64-Shipping_BPsmpUXhF6

But it also works on the player object. Let's say you want to save all client side options of your game inside an easy to access table:

Game.GetLocalPlayer().clientUserData.gameOptions = {}--we first create our table

Game.GetLocalPlayer().clientUserData.gameOptions.sizeOfUI = 15
Game.GetLocalPlayer().clientUserData.gameOptions.colorOfUI = "RED"

And you can read from or write to that table from any other client script. And so you can for example initialize the gameOptions table in one script then fill it with data from another script.

You can also store functions is these user data tables, the same way you store variables. You define the function in one script:

Game.GetLocalPlayer().clientUserData.myFunction = function(text) print(text) end

And then call that function from any other script in the same context:

Game.GetLocalPlayer().clientUserData.myFunction("It works!")

All in all, these user data tables are very useful. The only downside I see is that "clientUserData" and "serverUserData" are annoying to type and it won't make your code easier to read..

b. Importing the script object:

Another useful way of communicating between scripts is to simply find script B from script A and then access the properties and functions of script B using .context, like so:

This is the content of Script B. We want to access these properties and functions from Script A:

myNumber = 9

function SaySomething(theText)
	print(theText)
end

And this is the content of Script A:

local scriptB = World.FindObjectByName("Script B")--we find Script B

Task.Wait(1)-- we wait a little for Script B to be ready
print(scriptB.context.myNumber)
scriptB.context.SaySomething("This works very well!")

Notice that you should not make the variables or functions local in Script B if you want to access them from Script A. Also, it is worth noting that when using context to call a function, you use a dot "." and not two dots ":".

c. Broadcasting events:

Another easy way to communicate between scripts in the same context is to simply broadcast an event. Since the context is the same and the broadcast is not networked, there are no limits of size or rate.

This is the content of Script A where we connect the event, in other words, we define what will happen when the event is broadcast from Script B:

function DoSomething()
    --doing something here
end
Events.Connect("THE_SIGNAL", DoSomething)

And this is the content of Script B, where we broadcast the event:

Events.Broadcast("THE_SIGNAL")

Sometimes, broadcasting an event in the same context is the best solution. You can also have many scripts that listen for the same broadcast.

d. Using the Lua global table:

Another easy way to access and modify the same data from multiple scripts is to use the Lua global table with the help of _G, like so:

_G.myVariable = 13

myVariable is now globally accessible from any script in the same context by just typing _G.myVariable. Here is a more detailed tutorial on the global table.

e. Using "require()":

This one is a little bit different: you can only require the ID of a script, usually through an Asset Reference, that is, a script that is not in the hierarchy but which sits in you Project Content tab. Typically, this is used when you need to access a tools and utilities API (Application Programming Interface). So, let's say we want to require Script B from Script A, we first need an asset reference that points to Script B:

This is the content of Script B:

local API = {}

function API.PrintSomething(text)
    print(text)
end

return API

And then in Script A, all we need is this line:

local API = require(script:GetCustomProperty("ScriptB"))

And then we can use the functions of Script B this way:

API.PrintSomething("Script A is talking to Script B")

Note that when you require() it, Script B is fully executed, just once.


Conclusion:

As you can see, there are many ways to communicate between scripts in the same context. Which way to use is up to you and depends on your particular needs. Some methods are more adapted to some situations. But don't forget that sometimes, a small adjustment to your architecture can do wonders and eliminate the need to communicate between scripts.

Now, let's look at all the ways the server and client can communicate!




II. COMMUNICATING BETWEEN SCRIPTS IN DIFFERENT CONTEXTS

Communication from a server script to a client script and vice versa is really different from communication between scripts in the same context. The difference is obviously the fact that the server and the client are not the same computer, which means that any data you want to share between the two computers will have to transit through the internet, and for that reason you will find that there is always some kind of limit of size or rate to the data you can send. If there wasn't any limit, then you could send big chunks of data which would take ages and would make your game unplayable. Because on top of what you, the game creator, are able to send, Core is also adding its own data: every time you move your character, or jump, or use an ability, or type in the chat box, etc., a signal is sent from your computer to the server. And there is even more data that is sent from the server to your computer, like the position of all the players around you in the game, and their actions, and their player resources, etc.. And of course, the more networked objects exist in your game, the more bandwidth is used to share that data across all the clients.

Communication from the server to the client(s) and vice versa is vital to any multiplayer game, but anytime you need to do so, you have to ask yourself the following question: do I want my message to arrive quickly, or do I rather want my message to arrive safely? Based on your answer to that question will depend the method you're going to use to send the data.

1) BROADCASTING EVENTS:

Regarding the broadcasting of networked events between server and client(s), the maximum size a networked event can send is 128 bytes and all networked events are subjected to a rate limit of 10 events per second. Since Core Update 1.0.223, events between 129 and 1280 bytes (inclusive) can be sent without triggering warning messages. These larger events count against the internal bandwidth limit of Core.

As you can see, networked events are subject to certain limits which makes them not 100% reliable, even though they are the quickest way to send a networked signal. There is a possibility the event won't be received if you broadcast too many of them too quickly or if the size of the data you're sending is too big.

That said, let's have quick look at each event type.

a. Event from client to server:

The "official" way of sending custom information to the server from a client is to broadcast an event using Events.BroadcastToServer(eventName). This is the quick and by the book way to send a signal from the client to the server.

This is the client script, it broadcasts an event called THE_SIGNAL_NAME and also a message that the server will print:

Events.BroadcastToServer("THE_SIGNAL_NAME", "This is the message!")

And this is the server script in which we define the function that will be called when the event is received. We need to use Events.ConnectForPlayer:

function DoSomething(player, messageToPrint)
    print(player.name .. " has requested the server to print something: " .. messageToPrint)
end
Events.ConnectForPlayer("THE_SIGNAL_NAME", DoSomething)

The first parameter of the function must be the player who sent the event, and then after that comes any custom data that the player has sent, in this case the client is sending a string. Here is the result:
Platform-Win64-Shipping_SwHqc5GvJT

b. Events from server to client:

There are two ways to send a networked event from the server to the client: to a single player with Events.BroadcastToPlayer, or to all players at the same time with Events.BroadcastToAllPlayers. The only difference between the two is that in the case of the single player broadcast, you obviously need to specify which player will be the recipient of the broadcast. Note that in the case of Events.BroadcastToAllPlayers, the broadcast is counted as only one event broadcast towards the limit of 10 per second, regardless of the number of players.

This is the server script:

Events.BroadcastToPlayer(player, "THE_SIGNAL_NAME", customData1, customData2)

Then in the client script:

function DoSomething(customData1, customData2)
   --do something
end
Events.Connect("THE_SIGNAL_NAME", DoSomething)

In the case of Events.BroadcastToAllPlayers, the process is the same except that you don't specify the player target:

Events.BroadcastToAllPlayers("THE_SIGNAL_NAME", customData1, customData2)

2) SETTING A DYNAMIC CUSTOM PROPERTY:

Formerly known as SetNetworkedCustomProperty, it has changed form and name to become SetCustomProperty. It allows the server to change the value of a custom property of a networked object at run time and it enables a client script to listen to any change happening to that custom property with the help of customPropertyChangedEvent. Here are the conditions for this to work:

  • The object must be server side (in the default context, not in a server context) and set as Networked

  • The custom property must be set as Dynamic by right clicking on it.

In the following example, we use a cube as the networked object and we set a custom property of type string named "GameState".

This is the content of the client script which is going to listen to any change to the dynamic property. We first find the cube in the hierarchy, then we attach the customPropertyChangedEvent to it and we tell the handler function to print the new value of the custom property that's changed. In this example, the server sets the "GameState" custom property to "Lobby". As you can see, the event feeds the handler function with the object and the property that has changed.

local networkedCube = World.FindObjectByName("Cube")

function OnGameStateUpdate(object, property)
	local gameState = object:GetCustomProperty(property)
	print("CLIENT: ".. gameState)
end
networkedCube.customPropertyChangedEvent:Connect(OnGameStateUpdate)

Then in a server script, we can set the value of the custom property and the client will know about it:

local networkedCube = World.FindObjectByName("Cube")

Task.Wait(1)

networkedCube:SetCustomProperty("GameState", "Lobby")

The result in the event log:
Platform-Win64-Shipping_SdqpKxFmJw

If you look at the custom property while the preview is running, it will look like this:

Conclusion:
You can use this to transmit any type of data that is supported by custom properties, but it becomes really useful when you use it to transmit long compressed strings as there is no limit of size to the custom property, and it will just take the time it needs to transmit the data. Note that the event change will be fired on the client no matter the size of the value, but if you try to use the data in the custom property while it's not completely transmitted, you might get errors.

The other advantage of using this is that any player joining the game later will be able, for example, to read the state of the game through the custom property and able to update UI and other things.


3) USING PRIVATE NETWORKED DATA:

Another recent addition to the arsenal is SetPrivateNetworkedData which can be used to send data to a specific player with no size limit. The particularity here is that there is a key attached to each data set, and data sets can be retrieved later by using the specific key with the help of GetPrivateNetworkedData(key). We can retrieve a list of all keys attached to a player with GetPrivateNetworkedDataKeys. There is also a privateNetworkedDataChangedEvent which can be used on the client to execute code when the server sets private data for the local player.

This is the server script where we set some data to a specific player with a key named "Inventory":

local data = {"Sword", "Gun", "Bomb"}
player:SetPrivateNetworkedData("Inventory", data)

And this is the client script where we attach an event listener to the local player that's going to listen to any private data change. In this example, we first check what the key is before doing anything as we only want to catch events related to the inventory:

function OnInventoryUpdate(player, key)
    if key == "Inventory" then
        local data = Game.GetLocalPlayer():GetPrivateNetworkedData(key)
        UpdateInventoryUI(data)--custom function that will update player inventory UI
    end
end
Game.GetLocalPlayer().privateNetworkedDataChangedEvent:Connect(OnInventoryUpdate)

And as said above, the client can, at any time, retrieve an array of all the keys and deal with the data accordingly. Here we use the for loop which allows us to check each item of the array:

for _, key in ipairs(Game.GetLocalPlayer():GetPrivateNetworkedDataKeys()) do
    local data = Game.GetLocalPlayer():GetPrivateNetworkedData(key)

    if key == "Inventory" then
        UpdateInventoryUI(data)
    elseif key == "Skills" then
        UpdateSkillsUI(data)
    elseif key == "Housing" then
        UpdateHousingUI(data)
    end
end

So, to recapitulate:

  • SetPrivateNetworkedData is used on the server to send data to the player

  • privateNetworkedDataChangedEvent can be used on the client to listen to private data changes

  • GetPrivateNetworkedData can be used to retrieve specific data with the help of a specific key

  • GetPrivateNetworkedDataKeys is used to retrieve an array of all the keys attached to a player

Note that private data really means private: a client script can only retrieve data or keys pertaining to the local player, and not of any other player.

Conclusion:
This is a great new addition to the API because before that, there was no way to send data to a specific player, except by broadcasting events which are limited in size and rate. It's also great because it totally eliminates the need for a networked object in the case where SetCustomProperty was used. And finally, the fact that the data is attached to keys and that the data can be retrieved later using a key can be very useful in some situations. You don't need to use privateNetworkedDataChangedEvent and can check the data whenever you need it, which is flexible.




III. USING PERSISTENT DATA STORAGE

If you want to push your games to the next level, there is no avoiding using persistent storage. It is the only way to save data related to your players when they leave the game, like for example the experience they have gained, any in game currency, acquired weapons, etc., and load it later when they rejoin. With patch 1.0.224, it has become possible to read and write persistent data that is not tied to a particular player but is tied to a Core creator account, which opens new possibilities. It has also become possible to write data of a player who is offline.

Here are the ways you can read and/or write to or from persistent storage:

  • Storage.GetPlayerData and Storage.SetPlayerData. These functions were introduced along with persistent storage and allow you to get and set the data of a player for a specific game. You can only use these functions on a player who is online right now in the current instance.

  • Storage.GetSharedPlayerData and Storage.SetSharedPlayerData allow you to get and set the data of a player but this works across multiple games. This was added later and enabled creators to use the same persistent data across a main game and its child games. You can only use these functions for a player who is online right now in the current instance.

  • Storage.GetOfflinePlayerData was introduced even later and allows you to read(not write) the data of a player who is not online in the current server instance.

  • Storage.GetSharedOfflinePlayerData is the same as above but allows you to get the shared data of a player.

Since patch 1.0.224 (December 2021):

  • Storage.GetConcurrentPlayerData and Storage.SetConcurrentPlayerData allow you to respectively read and write the concurrent data of a player who is offline using the ID of the player. This gives access to a table associated to a specific game and a specific player and thus doesn't require any storage key.

  • Storage.GetConcurrentSharedPlayerData and Storage.SetConcurrentSharedPlayerData are the same as above but they access the concurrent shared data of a player who is offline with the help of a Concurrent Player Storage key. This gives access to a table can be shared across multiple games and is associated to a specific player.

  • Storage.GetConcurrentCreatorData and Storage.SetConcurrentCreatorData allow you to get and set creator data which isn't tied to any particular game or player and requires a Concurrent Creator Storage key.

Shared storage uses a sharedStorageKey which is associated to a Core account rather than to a specific game and these are limited to 8 per Core account. You can attribute them to your games in order for those games to share the same shared space. Note that normal single game storage and shared storage use different tables, which means that if you need it, you can use both at the same time if you have a lot of data to save. Furthermore, you can also attribute your 8 keys to a single game and its child games which means that you will have access to 8 x 32kb of storage space for each player if you really need it.

If you want to use persistent storage or concurrent persistent storage for your game, make sure to enable it in the properties of the Game Settings object, like so:
Platform-Win64-Shipping_qaDlnfjISN

a. Storage.GetPlayerData and Storage.SetPlayerData:

Then you can access player persistent storage from any server script. In the following example, we want to access player storage for every player that joins the game and check if it's the first time they ever join your game with the help of the veteran boolean variable. If it's not the first time they join the game, we retrieve their experience level and gold amount and we set corresponding player resources. If it's the first they join the game, then we set their experience level to 1 and starting gold to 100. Then after that we save the changes we've made to their player storage:

function OnPlayerJoined(player)
    local playerData = Storage.GetPlayerData(player) --we retrieve the player persistent storage table

    if playerData.veteran then --if the veteran flag was set previously, it means the player is not new
        player:SetResource("Xp", playeData.xp) --we retrieve the player xp
        player:SetResource("Gold", playeData.gold) --we retrieve the player's gold
    else --the player is a first timer because 'veteran' doesn't exist
        playerData.veteran = true --we set the veteran flag
        player:SetResource("Xp", 1) --we set starting xp level
        playerData.xpLevel = 1
        player:SetResource("Gold", 100) --we set starting gold
        playerData.gold = 100

        Storage.SetPlayerData(player, playerData) --we save the changes to persistent storage
    end
end
Game.playerJoinedEvent:Connect(OnPlayerJoined)

As you can see, getting and setting player storage is pretty straightforward. Here is what the actual file looks like:

If you want to take a look by yourself, you can find the file inside your project folder (File menu of the Core editor > Show Project in Explorer), then locate your_project\Temp\Storage.

Now let's say that during the game, you want to make sure that any modification to the amount of gold the player possess is saved into persistent storage. For that, you will use the event called resourceChangedEvent which will fire any time a player resource has changed. First, we need to attach the event listener to the player and to achieve that we will use onPlayerJoined:

function OnPlayerJoined(player)
    player.resourceChangedEvent:Connect(OnGoldChanged)
end
Game.playerJoinedEvent:Connect(OnPlayerJoined)

function OnGoldChanged(player, resourceName, newValue)
    if resourceName == "Gold" then --we check if the resource that has changed is actually "Gold"
        local playerData = Storage.GetPlayerData(player) --we retrieve the player persistent data table
        playerData.gold = newValue --we modify the gold entry in the table
        Storage.SetPlayerData(player, playerData) --we save the changes to persistent storage
    end
end

resourceChangedEvent feeds the handler function with the player concerned by the resource change, the name of the resource that has changed and the new value of that resource.

b. Storage.GetSharedPlayerData and Storage.SetSharedPlayerData:

The difference with shared data is that here you first have to setup a shared storage key. Open the Shared Storage window which can be found in the Windows menu of the Core editor, like so:

Click on the "Create New Shared Key" button at the bottom of the window and give it a name, then click Create. Now any time you want to set or get shared storage with that key, you have to have a reference to this shared key as a custom property of your script. So just drag and drop your shared key at the bottom of the properties of your script:

Now in your script, use that reference to specify which shared storage you want to access:

local propMySharedKey = script:GetCustomProperty("MySharedKey")

function OnPlayerJoined(player)
    local sharedPlayerData = Storage.GetSharedPlayerData(propMySharedKey, player)
    
    sharedPlayerData.newData = 18

    Storage.SetSharedPlayerData(propMySharedKey, player,  sharedPlayerData)
    
end
Game.playerJoinedEvent:Connect(OnPlayerJoined)

Now in any of your games where you want to have access to the same shared data, you need to use the same shared key in your scripts.

c. Storage.GetOfflinePlayerData and Storage.GetSharedOfflinePlayerData:

These two last functions are pretty straightforward and speak of themselves. They simply enable you to get the persistent storage of a player who is not online right now in the current server instance. In the case of the offline shared storage, you still need a shared key. Since these functions are concerning players that are offline, they take a string player ID and not the player object.

I have taken the following example straight from Core documentation because it is very a good one. What it does is it uses a leaderboard in order to get a player ID, then it feeds the player ID to the offline functions in order to retrieve more information about the players on the leaderboard. It first checks if the player is online or offline then it uses the online or offline version of the function accordingly. The online version of the function is faster than the offline version.

Here is the full description from the Core docs:
"In this example a global leaderboard is enriched with additional data about the player, in this case just their Level, but other data could be included when filling the leaderboard with information. To do this, the script combines a few different concepts about player data. First, the leaderboard data itself provides a list of players for which we then fetch additional data. It's likely the player is not connected to the server, thus offline storage is used, but if they are, regular storage is faster and doesn't yield the thread. Finally, the game may have defined a shared key, resulting in 4 different ways in which the additional player data (level number) is retrieved."

local LEADERBOARD_REF = script:GetCustomProperty("LeaderboardRef")
local STORAGE_KEY = script:GetCustomProperty("StorageKey")

-- Wait for leaderboards to load.
-- If a score has never been submitted it will stay in this loop forever
while not Leaderboards.HasLeaderboards() do
    Task.Wait(1)
end

local leaderboard = Leaderboards.GetLeaderboard(LEADERBOARD_REF, LeaderboardType.GLOBAL)
for i, entry in ipairs(leaderboard) do
    local playerId = entry.id
    local player = Game.FindPlayer(playerId)
    local data
    if player then
        -- The player is on this server, access data directly
        if STORAGE_KEY and STORAGE_KEY.isAssigned then
            -- If there is a shared game key
            data = Storage.GetSharedPlayerData(STORAGE_KEY, player) -- method 1
        else
            data = Storage.GetPlayerData(player) -- method 2
        end
    else
        -- Player is not here, use offline storage. This yields the thread
        if STORAGE_KEY and STORAGE_KEY.isAssigned then
            -- If there is a shared game key
            data = Storage.GetSharedOfflinePlayerData(STORAGE_KEY, playerId) -- method 3
        else
            data = Storage.GetOfflinePlayerData(playerId) -- method 4
        end
    end
    -- Get the additional data
    local playerLevel = data["level"] or 0

    print(i .. ")", entry.name, ":", entry.score, "- Level " .. playerLevel)
end

d. Concurrent storage:

These functions are called "concurrent" because since they can access and write data of a player who is not online in the current server instance or not online at all on Core, it might be the case that multiple server instances try to write data at the same time on the same table. For that reason, these functions may yield until they can have access to the most up to date data, and for that reason also, when you want to Set the data, you have to provide a function which will be executed only once the most up to date data has been acquired. The same goes for concurrent creator storage. Once the SetConcurrent... functions have a lock on the data, no other server instance can modify it. Thus a queue is created and each call to a SetConcurrent... function must wait for its turn.

To understand the problem, let's take a simple example where in all your games, you want to display the number of players currently playing any of your games. Since what you want to achieve isn't tied to any particular game or any particular player, your only possibility is to use concurrent creator storage. Now imagine that you have 20 active games, and each game has 10 full server instances (you're a very successful game creator!). Imagine the mess if the scripts in every one of these instances were trying to write data at the same time: everything would always be overwritten! And so these concurrent functions should assure you that any data manipulation you make won't be overwritten.

Getting concurrent data is pretty straightforward and works pretty much the same way as getting non concurrent data, with the only difference being that getting concurrent data may yield if there is a SetConcurrent... function being executed on the data you're trying to get. On the other hand, setting concurrent data requires that you provide a function that will be executed only once the data is available. So let's see that in more detail.

In this little example, we are going to update a value on concurrent storage every time 10 enemies are killed in any instance of any of your games. Since this data is not tied to any specific player or game, the only way to achieve that is to use concurrent creator storage. This is the content of a server script that you would put in any of your games:

if numberOfEnemiesKilled == 10 then
    Storage.SetConcurrentCreatorData(storageKey, function(data)
        data.totalNumberOfEnemiesKilled = data.totalNumberOfEnemiesKilled + 10
        print("Total number of enemies killed: ", data.totalNumberOfEnemiesKilled)
        numberOfEnemiesKilled = 0
        return data
   end )
end

function(data) is the function which will be executed once SetConcurrentCreatorData has acquired a lock on the most recent data, so that you can be sure that the data variable contains the most recent version accessed with that particular storage key. Then when we have updated the data table by adding 10 to the totalNumberOfEnemiesKilled, we need to return the data table so that SetConcurrentCreatorData knows what it has to save to concurrent storage.

Conclusion:
The addition of concurrent storage has not made Core persistent storage system more clear and easier to use, but it has added more possibilities and that is always welcome. We have now access to 5 distinct tables of 32kb each:

  • Basic player storage
  • Shared storage
  • Concurrent player storage
  • Concurrent shared player storage
  • Concurrent creator storage

And a creator can have 1 concurrent player key per game, up to 8 shared storage keys per game, up to 16 concurrent shared player keys, and up to 16 concurrent creator keys. So if you multiply all that, it is very unlikely that you run out of storage space for your game any time soon.

Which type of storage key do you need to create?

10 Likes

Is there a lesson in between this one and the Beginner lesson? It seems like a large jump in being able to understand compared to the first one? Or maybe that is the next step?